diff --git a/custom_components/protocol_wizard/__init__.py b/custom_components/protocol_wizard/__init__.py index 017e9ab..900d797 100644 --- a/custom_components/protocol_wizard/__init__.py +++ b/custom_components/protocol_wizard/__init__.py @@ -52,6 +52,7 @@ CONF_TEMPLATE_APPLIED, CONF_ENTITIES, CONF_REGISTERS, + CONF_SLAVES, ) @@ -138,7 +139,138 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create protocol-specific client try: if protocol_name == CONF_PROTOCOL_MODBUS: - client = await _create_modbus_client(hass, config, entry) + # Get list of slaves (defaults to single slave from CONF_SLAVE_ID for backward compatibility) + slaves = entry.options.get(CONF_SLAVES, []) + + _LOGGER.error("========== NEW CODE RUNNING! Entry: %s, has CONF_SLAVES: %s, count: %d ==========", + entry.title, slaves is not None and len(slaves) > 0, len(slaves) if slaves else 0) + + if not slaves: + # Backward compatibility: no slaves defined = use CONF_SLAVE_ID and global CONF_REGISTERS + default_slave_id = config.get(CONF_SLAVE_ID, 1) + # Check if there are entities in the old location (backward compatibility) + old_registers = entry.options.get(CONF_REGISTERS, []) + + # Check if there's a pending template from config_flow + pending_template = entry.options.get(CONF_TEMPLATE) + + _LOGGER.error("========== MIGRATION STARTING: slave_id=%d, %d entities, template=%s ==========", + default_slave_id, len(old_registers), pending_template or "None") + + # Log first entity as example + if old_registers: + first_entity = old_registers[0] + _LOGGER.error("========== FIRST ENTITY: name=%s, address=%s, data_type=%s ==========", + first_entity.get("name"), first_entity.get("address"), + first_entity.get("data_type")) + + # Build slave structure + slave_data = { + "slave_id": default_slave_id, + "name": entry.title or "Primary", + "registers": old_registers # Migrate old entities to slave AS-IS + } + + # CRITICAL FIX: Copy pending template to slave so it gets loaded + if pending_template: + slave_data["template"] = pending_template + _LOGGER.info("Migrating template '%s' to slave structure", pending_template) + + slaves = [slave_data] + + # IMPORTANT: Save the migration to options so it persists + options = dict(entry.options) + options[CONF_SLAVES] = slaves + # Remove old CONF_REGISTERS and CONF_TEMPLATE (moved to slave) to complete migration + options.pop(CONF_REGISTERS, None) + options.pop(CONF_TEMPLATE, None) + hass.config_entries.async_update_entry(entry, options=options) + _LOGGER.error("========== MIGRATION COMPLETE ==========") + else: + _LOGGER.error("========== USING EXISTING SLAVES: %d slaves ==========", len(slaves)) + + # Create a coordinator for each slave + coordinators_created = [] + for idx, slave_info in enumerate(slaves): + slave_id = slave_info["slave_id"] + slave_name = slave_info.get("name", f"Slave {slave_id}") + + _LOGGER.info("[Modbus Setup] Creating coordinator for slave %d (%s), %d entities", + slave_id, slave_name, len(slave_info.get('registers', []))) + + # Override slave_id in config for this slave + slave_config = dict(config) + slave_config[CONF_SLAVE_ID] = slave_id + + # Create client (uses shared connection via existing caching) + client = await _create_modbus_client(hass, slave_config, entry) + + # Create coordinator with slave-specific entity list + update_interval = entry.options.get(CONF_UPDATE_INTERVAL, 10) + + coordinator = CoordinatorClass( + hass=hass, + client=client, + config_entry=entry, + update_interval=timedelta(seconds=update_interval), + ) + + # IMPORTANT: Store slave_id in coordinator so it knows which entities to read + coordinator.slave_id = slave_id + coordinator.slave_index = idx # Index in slaves list + + # Load template for this specific slave if specified + slave_template = slave_info.get("template") + if slave_template and not slave_info.get("template_applied"): + _LOGGER.info("Loading template '%s' for slave %d (%s)", slave_template, slave_id, slave_name) + # Load template entities for THIS slave + template_entities = await load_template(hass, protocol_name, slave_template) + if template_entities: + # Update this slave's registers + slave_info["registers"] = template_entities + slave_info["template_applied"] = True + + # Save back to options + options = dict(entry.options) + options[CONF_SLAVES] = slaves + hass.config_entries.async_update_entry(entry, options=options) + + await coordinator.async_config_entry_first_refresh() + + # IMPORTANT: Always use consistent coordinator_key format + # This prevents duplicate devices when adding/removing slaves + coordinator_key = f"{entry.entry_id}_slave_{slave_id}" + + # Store coordinator_key in coordinator for device identification + coordinator.coordinator_key = coordinator_key + + hass.data[DOMAIN]["coordinators"][coordinator_key] = coordinator + + # BACKWARD COMPAT: Also store first slave with entry.entry_id for platform access + if idx == 0: + hass.data[DOMAIN]["coordinators"][entry.entry_id] = coordinator + + coordinators_created.append((coordinator_key, slave_name, slave_id)) + + # Create device registry entries for each slave + device_registry = dr.async_get(hass) + for coordinator_key, slave_name, slave_id in coordinators_created: + devicename = entry.title or entry.data.get(CONF_NAME) or f"{protocol_name.title()} Device" + if len(slaves) > 1: + devicename = f"{devicename} - {slave_name}" + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, coordinator_key)}, + name=devicename, + manufacturer=protocol_name.title(), + model=f"Protocol Wizard (Slave {slave_id})", + configuration_url=f"homeassistant://config/integrations/integration/{entry.entry_id}", + ) + + # Platforms (forward to all platforms once for all slaves) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + elif protocol_name == CONF_PROTOCOL_SNMP: client = _create_snmp_client(config) elif protocol_name == CONF_PROTOCOL_MQTT: @@ -152,55 +284,60 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to create client for %s: %s", protocol_name, err) return False - # Create coordinator - update_interval = entry.options.get(CONF_UPDATE_INTERVAL, 10) - - coordinator = CoordinatorClass( - hass=hass, - client=client, - config_entry=entry, - update_interval=timedelta(seconds=update_interval), - ) - - template_name = entry.options.get(CONF_TEMPLATE) - - if (template_name and not entry.options.get(CONF_TEMPLATE_APPLIED)): - _LOGGER.info("Loading template '%s' for new device", template_name) - await _load_template_into_options(hass, entry, protocol_name, template_name) - - # mark as applied - options = dict(entry.options) - options[CONF_TEMPLATE_APPLIED] = True - hass.config_entries.async_update_entry(entry, options=options) - - - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN]["coordinators"][entry.entry_id] = coordinator -# devicename = entry.data.get(CONF_NAME, f"{protocol_name.title()} Device") - devicename = entry.title or entry.data.get(CONF_NAME) or f"{protocol_name.title()} Device" - # CREATE DEVICE REGISTRY ENTRY - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.entry_id)}, - name=devicename, - manufacturer=protocol_name.title(), - model="Protocol Wizard", - configuration_url=f"homeassistant://config/integrations/integration/{entry.entry_id}", - ) - - # Platforms - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # For non-Modbus protocols, create single coordinator + if protocol_name != CONF_PROTOCOL_MODBUS: + # Create coordinator + update_interval = entry.options.get(CONF_UPDATE_INTERVAL, 10) + + coordinator = CoordinatorClass( + hass=hass, + client=client, + config_entry=entry, + update_interval=timedelta(seconds=update_interval), + ) + + template_name = entry.options.get(CONF_TEMPLATE) + + if (template_name and not entry.options.get(CONF_TEMPLATE_APPLIED)): + _LOGGER.info("Loading template '%s' for new device", template_name) + await _load_template_into_options(hass, entry, protocol_name, template_name) + + # mark as applied - but reload entry first to get the updated options! + entry = hass.config_entries.async_get_entry(entry.entry_id) + options = dict(entry.options) + options[CONF_TEMPLATE_APPLIED] = True + hass.config_entries.async_update_entry(entry, options=options) + + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN]["coordinators"][entry.entry_id] = coordinator + devicename = entry.title or entry.data.get(CONF_NAME) or f"{protocol_name.title()} Device" + + # CREATE DEVICE REGISTRY ENTRY + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + name=devicename, + manufacturer=protocol_name.title(), + model="Protocol Wizard", + configuration_url=f"homeassistant://config/integrations/integration/{entry.entry_id}", + ) + + # Platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Services (register once) if not hass.data[DOMAIN].get("services_registered"): await async_setup_services(hass) hass.data[DOMAIN]["services_registered"] = True - # Frontend - await async_install_frontend_resource(hass) - await async_register_card(hass, entry) + # Frontend (register once, not per entry) + if not hass.data[DOMAIN].get("frontend_registered"): + await async_install_frontend_resource(hass) + await async_register_card(hass, entry) + hass.data[DOMAIN]["frontend_registered"] = True return True @@ -221,15 +358,28 @@ async def _load_template_into_options( _LOGGER.warning("Template %s is empty", template_name) return - # Determine config key - config_key = "registers" if protocol == CONF_PROTOCOL_MODBUS else "entities" - # Update options with template entities new_options = dict(entry.options) - new_options[config_key] = template_data + + if protocol == CONF_PROTOCOL_MODBUS: + # For Modbus, check if we have slave structure + slaves = new_options.get(CONF_SLAVES, []) + if slaves: + # Put entities into first slave's registers + slaves[0]["registers"] = template_data + new_options[CONF_SLAVES] = slaves + _LOGGER.info("Loaded %d entities from template '%s' into slave %d", + len(template_data), template_name, slaves[0]["slave_id"]) + else: + # Fallback to old structure (shouldn't happen after migration) + new_options[CONF_REGISTERS] = template_data + _LOGGER.info("Loaded %d entities from template '%s' (old structure)", len(template_data), template_name) + else: + # Non-Modbus protocols use CONF_ENTITIES + new_options[CONF_ENTITIES] = template_data + _LOGGER.info("Loaded %d entities from template '%s'", len(template_data), template_name) hass.config_entries.async_update_entry(entry, options=new_options) - _LOGGER.info("Loaded %d entities from template '%s'", len(template_data), template_name) except Exception as err: _LOGGER.error("Failed to load template %s: %s", template_name, err) @@ -394,14 +544,22 @@ async def handle_add_entity(call: ServiceCall): # Determine protocol and config key protocol = entry.data.get(CONF_PROTOCOL, CONF_PROTOCOL_MODBUS) - if protocol == CONF_PROTOCOL_MODBUS: - config_key = CONF_REGISTERS - else: - config_key = CONF_ENTITIES - # Get current entities + # Get current entities based on protocol and structure current_options = dict(entry.options) - entities = list(current_options.get(config_key, [])) + + if protocol == CONF_PROTOCOL_MODBUS: + # Check if we have slaves (new structure) + slaves = current_options.get(CONF_SLAVES, []) + if slaves: + # Multi-slave: add to first slave's registers + entities = list(slaves[0].get("registers", [])) + else: + # Old structure fallback + entities = list(current_options.get(CONF_REGISTERS, [])) + else: + # Non-Modbus protocols + entities = list(current_options.get(CONF_ENTITIES, [])) # Build new entity config new_entity = { @@ -438,7 +596,20 @@ async def handle_add_entity(call: ServiceCall): # Add the new entity entities.append(new_entity) - current_options[config_key] = entities + + # Save back to correct location + if protocol == CONF_PROTOCOL_MODBUS: + slaves = current_options.get(CONF_SLAVES, []) + if slaves: + # Save to first slave's registers + slaves[0]["registers"] = entities + current_options[CONF_SLAVES] = slaves + else: + # Old structure fallback + current_options[CONF_REGISTERS] = entities + else: + # Non-Modbus protocols + current_options[CONF_ENTITIES] = entities # Update the config entry hass.config_entries.async_update_entry(entry, options=current_options) diff --git a/custom_components/protocol_wizard/config_flow.py b/custom_components/protocol_wizard/config_flow.py index 8662cd1..4cc71d8 100644 --- a/custom_components/protocol_wizard/config_flow.py +++ b/custom_components/protocol_wizard/config_flow.py @@ -77,6 +77,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo if user_input is not None: self._protocol = user_input.get(CONF_PROTOCOL, CONF_PROTOCOL_MODBUS) + unique_id = f"{DOMAIN}_{user_input.get('device_id', 'default')}_{self._protocol}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() if self._protocol == CONF_PROTOCOL_MODBUS: return await self.async_step_modbus_common() diff --git a/custom_components/protocol_wizard/const.py b/custom_components/protocol_wizard/const.py index 3c8f8a6..d9d3af7 100644 --- a/custom_components/protocol_wizard/const.py +++ b/custom_components/protocol_wizard/const.py @@ -50,6 +50,7 @@ CONF_PROTOCOL_KNX = "knx" CONF_PROTOCOL = "protocol" CONF_IP = "IP" +CONF_SLAVES = "slaves" # Defaults DEFAULT_SLAVE_ID = 1 diff --git a/custom_components/protocol_wizard/entity_base.py b/custom_components/protocol_wizard/entity_base.py index 185088e..8c356d6 100644 --- a/custom_components/protocol_wizard/entity_base.py +++ b/custom_components/protocol_wizard/entity_base.py @@ -26,6 +26,7 @@ CONF_PROTOCOL_MQTT, CONF_PROTOCOL_BACNET, CONF_PROTOCOL, + CONF_SLAVES, ) from .protocols.base import BaseProtocolCoordinator @@ -187,10 +188,40 @@ def _unique_id(self, entity_config: dict) -> str: async def sync_entities(self) -> None: """Create, update, and remove entities based on current config.""" config_key = self._get_entities_config_key() - current_configs = self.entry.options.get(config_key, []) + + # For Modbus, check if we have the new CONF_SLAVES structure + protocol = self.entry.data.get(CONF_PROTOCOL, CONF_PROTOCOL_MODBUS) + if protocol == CONF_PROTOCOL_MODBUS: + slaves = self.entry.options.get(CONF_SLAVES, []) + if slaves: + # Multi-slave mode: get entities from coordinator's slave + # The coordinator has slave_id and slave_index attributes set + if hasattr(self.coordinator, 'slave_index'): + slave_index = self.coordinator.slave_index + if slave_index < len(slaves): + current_configs = slaves[slave_index].get('registers', []) + _LOGGER.debug("Reading entities from slave %d (index %d): %d entities", + self.coordinator.slave_id, slave_index, len(current_configs)) + else: + _LOGGER.warning("Slave index %d out of range (total slaves: %d)", + slave_index, len(slaves)) + current_configs = [] + else: + # Single slave mode (backward compatibility) + current_configs = slaves[0].get('registers', []) + _LOGGER.debug("Reading entities from single slave: %d entities", len(current_configs)) + else: + # Old structure fallback + current_configs = self.entry.options.get(config_key, []) + _LOGGER.debug("Reading entities from old structure (%s): %d entities", + config_key, len(current_configs)) + else: + # Non-Modbus protocols use the config key directly + current_configs = self.entry.options.get(config_key, []) + desired_ids = set() new_entities: list[Entity] = [] - + for config in current_configs: if not self._should_create_entity(config): continue diff --git a/custom_components/protocol_wizard/number.py b/custom_components/protocol_wizard/number.py index 9876f94..cb2bcc3 100644 --- a/custom_components/protocol_wizard/number.py +++ b/custom_components/protocol_wizard/number.py @@ -55,9 +55,12 @@ async def async_setup_entry( ): """Set up number entities for any protocol.""" coordinator = hass.data[DOMAIN]["coordinators"][entry.entry_id] - + + # Use coordinator_key if available (multi-slave), otherwise use entry.entry_id + device_identifier = getattr(coordinator, 'coordinator_key', entry.entry_id) + device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, + identifiers={(DOMAIN, device_identifier)}, name=entry.title or f"{coordinator.protocol_name.title()} Device", manufacturer=coordinator.protocol_name.title(), model="Protocol Wizard", diff --git a/custom_components/protocol_wizard/options_flow.py b/custom_components/protocol_wizard/options_flow.py index ff37d49..d39856a 100644 --- a/custom_components/protocol_wizard/options_flow.py +++ b/custom_components/protocol_wizard/options_flow.py @@ -16,7 +16,7 @@ delete_template, ) from homeassistant import config_entries -from homeassistant.helpers import selector +from homeassistant.helpers import selector, device_registry as dr #import asyncio from .const import ( DOMAIN, @@ -31,6 +31,7 @@ CONF_BYTE_ORDER, CONF_WORD_ORDER, CONF_REGISTER_TYPE, + CONF_SLAVES, ) _LOGGER = logging.getLogger(__name__) @@ -48,19 +49,41 @@ def __init__(self, config_entry: config_entries.ConfigEntry): self.protocol = config_entry.data.get(CONF_PROTOCOL, CONF_PROTOCOL_MODBUS) self.schema_handler = self._get_schema_handler() + + # NEW: Track which slave we're configuring (for Modbus multi-slave) + self._selected_slave_index: int | None = None - # Determine the correct config key based on protocol - if self.protocol == CONF_PROTOCOL_MODBUS: - config_key = CONF_REGISTERS - else: - config_key = CONF_ENTITIES # Future-proof for other protocols - - self._entities: list[dict] = list(config_entry.options.get(config_key, [])) + # Load entities based on context + self._entities: list[dict] = self._load_entities_for_context() self._edit_index: int | None = None @property def config_entry(self) -> config_entries.ConfigEntry: return self._config_entry + + def _load_entities_for_context(self) -> list[dict]: + """Load entities based on current context (slave or global).""" + if self.protocol == CONF_PROTOCOL_MODBUS: + # Check if we're in slave context + if self._selected_slave_index is not None: + slaves = self._config_entry.options.get(CONF_SLAVES, []) + if slaves and self._selected_slave_index < len(slaves): + return list(slaves[self._selected_slave_index].get('registers', [])) + # Check if we have slaves (multi-slave mode) + slaves = self._config_entry.options.get(CONF_SLAVES, []) + if slaves and len(slaves) > 0: + # Multi-slave mode but no specific slave selected + # Check if this is a migrated single-slave (backward compat) + if len(slaves) == 1: + # Single slave - load its entities + return list(slaves[0].get('registers', [])) + # Multiple slaves - return empty, user must select a slave + return [] + # No slaves yet - backward compat mode + return list(self._config_entry.options.get(CONF_REGISTERS, [])) + else: + # Non-Modbus protocols + return list(self._config_entry.options.get(CONF_ENTITIES, [])) @staticmethod def _export_schema(): @@ -79,18 +102,196 @@ def _write_template(path: str, entities: list[dict]): async def async_step_init(self, user_input=None): menu_options = { "settings": "Settings", - "add_entity": "Add entity", + } + + # For Modbus with slaves structure, show slave selection + if self.protocol == CONF_PROTOCOL_MODBUS: + slaves = self._config_entry.options.get(CONF_SLAVES, []) + if slaves: + # Show slave management menu (allows adding more slaves) + slave_count = len(slaves) + if slave_count == 1: + menu_options["select_slave"] = f"⚙️ Manage Slaves (1 slave, add more)" + else: + menu_options["select_slave"] = f"⚙️ Manage Slaves ({slave_count} slaves)" + + # If single slave, also show entity shortcuts for convenience + if slave_count == 1: + menu_options["add_entity"] = "Add entity (quick)" + if self._entities: + menu_options["list_entities"] = f"Entities ({len(self._entities)})" + menu_options["edit_entity"] = "Edit entity (quick)" + else: + # No slaves - backward compat mode + menu_options["add_entity"] = "Add entity" + if self._entities: + menu_options["list_entities"] = f"Entities ({len(self._entities)})" + menu_options["edit_entity"] = "Edit entity" + else: + # Non-Modbus: normal entity management + menu_options["add_entity"] = "Add entity" + if self._entities: + menu_options["list_entities"] = f"Entities ({len(self._entities)})" + menu_options["edit_entity"] = "Edit entity" + + # Template options (always available) + menu_options.update({ "load_template": "Load template", "export_template": "Export template", "delete_template": "Delete user template", + }) + + return self.async_show_menu(step_id="init", menu_options=menu_options) + + async def async_step_select_slave(self, user_input=None): + """Select which slave to configure.""" + if user_input: + action = user_input.get("action") + + if action == "add_slave": + return await self.async_step_add_slave() + elif action.startswith("configure_"): + # Extract slave index + idx = int(action.split("_")[1]) + self._selected_slave_index = idx + self._entities = self._load_entities_for_context() + return await self.async_step_slave_menu() + elif action.startswith("delete_"): + # Delete slave + idx = int(action.split("_")[1]) + slaves = list(self._config_entry.options.get(CONF_SLAVES, [])) + if idx < len(slaves): + deleted = slaves.pop(idx) + deleted_slave_id = deleted.get('slave_id') + + # Clean up device registry entry for this slave + device_registry = dr.async_get(self.hass) + coordinator_key = f"{self._config_entry.entry_id}_slave_{deleted_slave_id}" + device = device_registry.async_get_device(identifiers={(DOMAIN, coordinator_key)}) + if device: + device_registry.async_remove_device(device.id) + _LOGGER.info("Removed device for slave %d from device registry", deleted_slave_id) + + options = dict(self._config_entry.options) + options[CONF_SLAVES] = slaves + self.hass.config_entries.async_update_entry(self._config_entry, options=options) + await self.hass.config_entries.async_reload(self._config_entry.entry_id) + _LOGGER.info("Deleted slave: %s", deleted.get('name')) + return await self.async_step_init() + + slaves = self._config_entry.options.get(CONF_SLAVES, []) + + options = {"add_slave": "➕ Add New Slave"} + for idx, slave in enumerate(slaves): + name = slave.get('name', f"Slave {slave['slave_id']}") + slave_id = slave['slave_id'] + entity_count = len(slave.get('registers', [])) + options[f"configure_{idx}"] = f"⚙️ {name} (ID {slave_id}) - {entity_count} entities" + options[f"delete_{idx}"] = f"🗑️ Delete: {name} (ID {slave_id})" + + return self.async_show_form( + step_id="select_slave", + data_schema=vol.Schema({ + vol.Required("action"): vol.In(options) + }), + description_placeholders={ + "info": "Select a slave to configure its entities, or add a new slave" + } + ) + + async def async_step_slave_menu(self, user_input=None): + """Menu for managing a specific slave's entities.""" + if self._selected_slave_index is None: + return await self.async_step_init() + + slaves = self._config_entry.options.get(CONF_SLAVES, []) + if not slaves or self._selected_slave_index >= len(slaves): + return await self.async_step_init() + + slave = slaves[self._selected_slave_index] + slave_name = slave.get('name', f"Slave {slave['slave_id']}") + + menu_options = { + "add_entity": "Add entity", + "load_template": "Load template for this slave", } + if self._entities: menu_options["list_entities"] = f"Entities ({len(self._entities)})" menu_options["edit_entity"] = "Edit entity" + + menu_options["back"] = "← Back to slave list" + + return self.async_show_menu( + step_id="slave_menu", + menu_options=menu_options, + ) - return self.async_show_menu(step_id="init", menu_options=menu_options) - + async def async_step_back(self, user_input=None): + """Go back to main menu.""" + # Clear slave selection + self._selected_slave_index = None + self._entities = self._load_entities_for_context() + return await self.async_step_init() + async def async_step_add_slave(self, user_input=None): + """Add a new slave to this connection.""" + errors = {} + + if user_input: + slaves = list(self._config_entry.options.get(CONF_SLAVES, [])) + + # Check for duplicate slave_id + new_slave_id = user_input["slave_id"] + if any(s["slave_id"] == new_slave_id for s in slaves): + errors["base"] = "duplicate_slave_id" + else: + # Build new slave entry + new_slave = { + "slave_id": new_slave_id, + "name": user_input.get("name", f"Slave {new_slave_id}"), + "registers": [] # Start with empty entity list + } + + # Handle template if selected + template_name = user_input.get("template") + if template_name and template_name != "none": + new_slave["template"] = template_name + # Template will be loaded on next integration reload by __init__.py + + slaves.append(new_slave) + + new_options = dict(self._config_entry.options) + new_options[CONF_SLAVES] = slaves + + self.hass.config_entries.async_update_entry(self._config_entry, options=new_options) + + # Reload to apply + await self.hass.config_entries.async_reload(self._config_entry.entry_id) + + return await self.async_step_init() + + # Get available templates + templates = await get_available_templates(self.hass, self.protocol) + template_choices = {"none": "No template (add entities manually)"} + template_choices.update(get_template_dropdown_choices(templates)) + + schema_dict = { + vol.Required("slave_id"): vol.All(vol.Coerce(int), vol.Range(min=1, max=247)), + vol.Optional("name"): str, + } + + if templates: + schema_dict[vol.Optional("template", default="none")] = vol.In(template_choices) + + return self.async_show_form( + step_id="add_slave", + data_schema=vol.Schema(schema_dict), + errors=errors, + description_placeholders={ + "info": "Add a new Modbus slave device. Optionally select a template to pre-configure entities." + } + ) # ------------------------------------------------------------------ # SETTINGS # ------------------------------------------------------------------ @@ -129,6 +330,9 @@ async def async_step_add_entity(self, user_input=None): errors = {} if user_input: + # Reload entities to get current state + self._entities = self._load_entities_for_context() + processed = self.schema_handler.process_input(user_input, errors, existing=None) if processed and not errors: self._entities.append(processed) @@ -287,6 +491,10 @@ async def async_step_load_template(self, user_input=None): errors={"base": "template_not_found"}, ) + # IMPORTANT: Reload entities from current config state + # (migration may have run since __init__) + self._entities = self._load_entities_for_context() + added = self.schema_handler.merge_template(self._entities, entities) if added == 0: @@ -388,10 +596,29 @@ def _load_template(path: str): def _save_entities(self): options = dict(self._config_entry.options) - config_key = CONF_REGISTERS if self.protocol == CONF_PROTOCOL_MODBUS else CONF_ENTITIES - options[config_key] = self._entities - # it says Async.. but is actually not? It returns a bool stating nothing changed but it has... - # anyway we changed this 20 times. It should stay as it is! + + if self.protocol == CONF_PROTOCOL_MODBUS: + # Check if we're in slave context + if self._selected_slave_index is not None: + # Save to specific slave's registers + slaves = list(options.get(CONF_SLAVES, [])) + if slaves and self._selected_slave_index < len(slaves): + slaves[self._selected_slave_index]['registers'] = self._entities + options[CONF_SLAVES] = slaves + else: + # Check if we have slaves at all + slaves = list(options.get(CONF_SLAVES, [])) + if slaves: + # Single slave mode - save to first slave + slaves[0]['registers'] = self._entities + options[CONF_SLAVES] = slaves + else: + # True backward compat: no slaves exist yet + options[CONF_REGISTERS] = self._entities + else: + # Non-Modbus + options[CONF_ENTITIES] = self._entities + # Update entry (synchronous) self.hass.config_entries.async_update_entry(self._config_entry, options=options) diff --git a/custom_components/protocol_wizard/protocols/modbus/coordinator.py b/custom_components/protocol_wizard/protocols/modbus/coordinator.py index 92e7ed9..a9ef06f 100644 --- a/custom_components/protocol_wizard/protocols/modbus/coordinator.py +++ b/custom_components/protocol_wizard/protocols/modbus/coordinator.py @@ -12,19 +12,19 @@ from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry from pymodbus.client.mixin import ModbusClientMixin +from .client import ModbusClient from ..base import BaseProtocolCoordinator from .. import ProtocolRegistry -from .client import ModbusClient -from .const import CONF_REGISTERS, TYPE_SIZES, reg_key +from .const import TYPE_SIZES, reg_key +from ...const import CONF_REGISTERS,CONF_SLAVES _LOGGER = logging.getLogger(__name__) -# Reduce noise from pymodbus -# Setting parent logger to CRITICAL to catch all sub-loggers +# Reduce noise from pymodbus - TEMPORARILY COMMENTED FOR DEBUG logging.getLogger("pymodbus").setLevel(logging.CRITICAL) logging.getLogger("pymodbus.logging").setLevel(logging.CRITICAL) -logging.getLogger("homeassistant.helpers.update_coordinator").setLevel(logging.CRITICAL) +# logging.getLogger("homeassistant.helpers.update_coordinator").setLevel(logging.CRITICAL) @ProtocolRegistry.register("modbus") class ModbusCoordinator(BaseProtocolCoordinator): @@ -57,10 +57,28 @@ async def _async_update_data(self) -> dict[str, Any]: if not await self._async_connect(): _LOGGER.warning("[Modbus] Could not connect to device — skipping update") - return {} - entities = self.my_config_entry.options.get(CONF_REGISTERS, []) + return {} + + # Get entities for THIS SPECIFIC SLAVE + # Check if we have slave_id set (multi-slave mode) + if hasattr(self, 'slave_id') and hasattr(self, 'slave_index'): + # Multi-slave mode: read from this slave's register list + slaves = self.my_config_entry.options.get(CONF_SLAVES, []) + if slaves and self.slave_index < len(slaves): + entities = slaves[self.slave_index].get('registers', []) + _LOGGER.error("========== LOADED %d ENTITIES FOR SLAVE %d (INDEX %d) ==========", + len(entities), self.slave_id, self.slave_index) + else: + _LOGGER.error("========== SLAVE INDEX %d NOT FOUND! TOTAL SLAVES: %d ==========", + self.slave_index, len(slaves)) + entities = [] + else: + # Backward compatibility: single slave mode, read from global CONF_REGISTERS + entities = self.my_config_entry.options.get(CONF_REGISTERS, []) + _LOGGER.error("========== LOADED %d ENTITIES FROM CONF_REGISTERS (BACKWARD COMPAT) ==========", len(entities)) if not entities: + _LOGGER.error("========== NO ENTITIES TO READ! ==========") return {} new_data = {} diff --git a/custom_components/protocol_wizard/select.py b/custom_components/protocol_wizard/select.py index 3a0e669..93ecd6d 100644 --- a/custom_components/protocol_wizard/select.py +++ b/custom_components/protocol_wizard/select.py @@ -44,9 +44,12 @@ async def async_setup_entry( ): """Set up select entities for any protocol.""" coordinator = hass.data[DOMAIN]["coordinators"][entry.entry_id] - + + # Use coordinator_key if available (multi-slave), otherwise use entry.entry_id + device_identifier = getattr(coordinator, 'coordinator_key', entry.entry_id) + device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, + identifiers={(DOMAIN, device_identifier)}, name=entry.title or f"{coordinator.protocol_name.title()} Device", manufacturer=coordinator.protocol_name.title(), model="Protocol Wizard", diff --git a/custom_components/protocol_wizard/sensor.py b/custom_components/protocol_wizard/sensor.py index 64fac92..fe73303 100644 --- a/custom_components/protocol_wizard/sensor.py +++ b/custom_components/protocol_wizard/sensor.py @@ -48,9 +48,12 @@ async def async_setup_entry( ): """Set up sensor entities for any protocol.""" coordinator = hass.data[DOMAIN]["coordinators"][entry.entry_id] - + + # Use coordinator_key if available (multi-slave), otherwise use entry.entry_id + device_identifier = getattr(coordinator, 'coordinator_key', entry.entry_id) + device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, + identifiers={(DOMAIN, device_identifier)}, name=entry.title or f"{coordinator.protocol_name.title()} Device", manufacturer=coordinator.protocol_name.title(), model="Protocol Wizard", diff --git a/custom_components/protocol_wizard/switch.py b/custom_components/protocol_wizard/switch.py index 4e1c8b5..2baaa85 100644 --- a/custom_components/protocol_wizard/switch.py +++ b/custom_components/protocol_wizard/switch.py @@ -49,8 +49,11 @@ async def async_setup_entry( """Set up switch entities.""" coordinator = hass.data[DOMAIN]["coordinators"][entry.entry_id] + # Use coordinator_key if available (multi-slave), otherwise use entry.entry_id + device_identifier = getattr(coordinator, 'coordinator_key', entry.entry_id) + device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, + identifiers={(DOMAIN, device_identifier)}, name=entry.title or f"{coordinator.protocol_name.title()} Device", manufacturer=coordinator.protocol_name.title(), model="Protocol Wizard",