diff --git a/README.md b/README.md index e9c3d1c..dfec1e7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ +These fixes are done by Claude AI and is currently used in my personal Hass instance. + +- Add OptionsFlow so mac_mapping/ip_range can be edited after initial setup via Configure, instead of requiring reinstall (fixes How to reconfigure? #14) +- Auto-reload the entry on options update so changes apply immediately +- Merge configuration.yaml mac_mapping_*/ip_range into the config entry on startup, since it was previously only used as a setup-wizard default and silently ignored afterward +- (Untested) Fix sensor.py so mac_mapping entries are collected by scanning all present keys instead of walking contiguously, which previously caused every entry above a numbering gap to be silently dropped + + # Home Assistant Network Scanner Integration This Home Assistant integration provides a network scanner that identifies all devices on your local network. Utilizing the provided IP range and MAC address mappings, it gives each identified device a user-friendly name and manufacturer information. diff --git a/custom_components/network_scanner/__init__.py b/custom_components/network_scanner/__init__.py index 62290d9..1b2679a 100644 --- a/custom_components/network_scanner/__init__.py +++ b/custom_components/network_scanner/__init__.py @@ -9,9 +9,42 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up Network Scanner from a config entry.""" + # Pull in any ip_range / mac_mapping_* keys defined in + # configuration.yaml that aren't already on this config entry. This is + # what actually makes "Option 2: Manually via configuration.yaml" from + # the README work post-setup -- previously YAML values were only ever + # used as suggested defaults in the one-time setup wizard and were + # silently ignored afterwards, so edits to configuration.yaml appeared + # to do nothing (requires an HA restart to be picked up, same as any + # other YAML change). + yaml_config = hass.data.get(DOMAIN, {}) + if isinstance(yaml_config, dict): + missing = { + key: value + for key, value in yaml_config.items() + if (key == "ip_range" or key.startswith("mac_mapping_")) + and key not in config_entry.data + } + if missing: + hass.config_entries.async_update_entry( + config_entry, data={**config_entry.data, **missing} + ) + await hass.config_entries.async_forward_entry_setups(config_entry, ["sensor"]) + + # Reload the entry whenever it's updated (e.g. via the options flow), + # so newly added/edited mac_mapping entries take effect immediately + # instead of requiring the integration to be removed and re-added. + config_entry.async_on_unload( + config_entry.add_update_listener(_async_update_listener) + ) + return True +async def _async_update_listener(hass, config_entry): + """Handle an update to the config entry by reloading it.""" + await hass.config_entries.async_reload(config_entry.entry_id) + async def async_unload_entry(hass, config_entry): """Unload a config entry.""" await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") diff --git a/custom_components/network_scanner/config_flow.py b/custom_components/network_scanner/config_flow.py index 13c1b19..61d2f16 100644 --- a/custom_components/network_scanner/config_flow.py +++ b/custom_components/network_scanner/config_flow.py @@ -1,18 +1,62 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback from .const import DOMAIN import logging _LOGGER = logging.getLogger(__name__) +# Always offer at least this many mac_mapping slots on the form. +MIN_MAC_MAPPING_SLOTS = 25 + +# Always offer this many *blank* slots past the highest slot already in +# use, so there's always room to add new devices instead of the form +# being sized exactly to what you already have. +EXTRA_BLANK_SLOTS = 15 + + +def _build_schema(current_data): + """Build the ip_range / mac_mapping schema, pre-filled from current_data. + + Shared by both the initial config flow and the options flow so that + values entered during setup show back up (and can be edited) later. + """ + schema_dict = { + vol.Required( + "ip_range", + description={"suggested_value": current_data.get("ip_range", "192.168.1.0/24")}, + ): str + } + + # Work out how many mac_mapping_N slots we need to show: enough to + # cover any already-saved entries (e.g. mac_mapping_70), plus a + # buffer of blank slots so there's always room to add more, plus a + # floor of MIN_MAC_MAPPING_SLOTS for brand-new entries. + highest_existing = 0 + for key in current_data: + if key.startswith("mac_mapping_"): + suffix = key[len("mac_mapping_"):] + if suffix.isdigit(): + highest_existing = max(highest_existing, int(suffix)) + + max_index = max(MIN_MAC_MAPPING_SLOTS, highest_existing + EXTRA_BLANK_SLOTS) + + for i in range(1, max_index + 1): + key = f"mac_mapping_{i}" + schema_dict[ + vol.Optional(key, description={"suggested_value": current_data.get(key)}) + ] = str + + return vol.Schema(schema_dict) + + class NetworkScannerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Network Scanner.""" - async def async_step_user(self, user_input=None): - def format_dict_for_printing(d): - return {k: str(v) for k, v in d.items()} + VERSION = 1 - """Manage the configurations from the user interface.""" + async def async_step_user(self, user_input=None): + """Manage the initial setup from the user interface.""" errors = {} # Load data from configuration.yaml @@ -22,40 +66,55 @@ def format_dict_for_printing(d): if user_input is not None: return self.async_create_entry(title="Network Scanner", data=user_input) - data_schema_dict = { - vol.Required("ip_range", description={"suggested_value": yaml_config.get("ip_range", "192.168.1.0/24")}): str - } - - # Add mac mappings with values from YAML if available - for i in range(1, 26): # Ensure at least 25 entries - key = f"mac_mapping_{i}" - if key in yaml_config: - suggested_value = yaml_config.get(key) - _LOGGER.debug("YAML Config key: %s", suggested_value) - else: - suggested_value = None # No value in YAML config - - # Add the optional field to the schema, with or without suggested_value - data_schema_dict[vol.Optional(key, description={"suggested_value": suggested_value})] = str - - # Continue to add more mappings if available in the YAML config - i = 26 - while True: - key = f"mac_mapping_{i}" - if key in yaml_config: - suggested_value = yaml_config.get(key) - _LOGGER.debug("YAML Config key: %s", suggested_value) - data_schema_dict[vol.Optional(key, description={"suggested_value": suggested_value})] = str - i += 1 - else: - break # Exit loop when no more mappings are found in the YAML config - - _LOGGER.debug("schema: %s", format_dict_for_printing(data_schema_dict)) - data_schema = vol.Schema(data_schema_dict) + schema = _build_schema(yaml_config) return self.async_show_form( step_id="user", - data_schema=data_schema, + data_schema=schema, + errors=errors, + description_placeholders={"description": "Enter the IP range and MAC mappings"}, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return NetworkScannerOptionsFlow() + + +class NetworkScannerOptionsFlow(config_entries.OptionsFlow): + """Handle reconfiguration of an existing Network Scanner entry. + + This is what makes the "Configure" button on the integration work, + so ip_range and mac_mapping_* entries can be added/edited/removed + after the integration has already been set up, without having to + delete and re-add it. + """ + + async def async_step_init(self, user_input=None): + errors = {} + + if user_input is not None: + # Drop empty optional fields so they don't linger as blank + # mac_mapping_N entries once a user clears them out. + cleaned = { + k: v + for k, v in user_input.items() + if k == "ip_range" or (v is not None and str(v).strip() != "") + } + + # Persist onto the config entry's data (this is what sensor.py + # reads from). Updating the entry fires update listeners, which + # __init__.py uses to reload the sensor with the new values. + self.hass.config_entries.async_update_entry(self.config_entry, data=cleaned) + return self.async_create_entry(title="", data={}) + + current_data = dict(self.config_entry.data) + schema = _build_schema(current_data) + + return self.async_show_form( + step_id="init", + data_schema=schema, errors=errors, - description_placeholders={"description": "Enter the IP range and MAC mappings"} + description_placeholders={"description": "Update the IP range and MAC mappings"}, ) diff --git a/custom_components/network_scanner/manifest.json b/custom_components/network_scanner/manifest.json index be34964..afca81f 100644 --- a/custom_components/network_scanner/manifest.json +++ b/custom_components/network_scanner/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/parvez/network_scanner/issues", "requirements": ["python-nmap"], - "version": "1.0.7" + "version": "1.0.8" } diff --git a/custom_components/network_scanner/sensor.py b/custom_components/network_scanner/sensor.py index 2edbbec..25188cb 100644 --- a/custom_components/network_scanner/sensor.py +++ b/custom_components/network_scanner/sensor.py @@ -102,31 +102,31 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Network Scanner sensor from a config entry.""" ip_range = config_entry.data.get("ip_range") _LOGGER.debug("ip_range: %s", config_entry.data.get("ip_range")) - - # Initialize mac_mappings list to ensure at least 25 entries - mac_mappings_list = [] - - # Ensure we have at least 25 entries, even if config is missing some - for i in range(25): - key = f"mac_mapping_{i+1}" - mac_mapping = config_entry.data.get(key, "") - mac_mappings_list.append(mac_mapping) - _LOGGER.debug("mac_mapping_%s: %s", i+1, mac_mapping) - - # Continue adding additional mac mappings if present in the config - i = 25 - while True: - key = f"mac_mapping_{i+1}" - if key in config_entry.data: - mac_mapping = config_entry.data.get(key) - mac_mappings_list.append(mac_mapping) - _LOGGER.debug("mac_mapping_%s: %s", i+1, mac_mapping) - i += 1 - else: - break + + # Collect every mac_mapping_N key present in the entry, regardless of + # numbering gaps. Previously this walked mac_mapping_26, 27, 28... + # contiguously and stopped at the first missing key, which meant + # deleting/clearing a single entry in the middle (e.g. mac_mapping_40) + # would silently drop every entry numbered above it, even though their + # data was still stored. Sorting and including whatever keys actually + # exist avoids that entirely. + def _slot_number(key): + suffix = key[len("mac_mapping_"):] + return int(suffix) if suffix.isdigit() else 0 + + mapping_items = sorted( + ( + (key, value) + for key, value in config_entry.data.items() + if key.startswith("mac_mapping_") and value + ), + key=lambda item: _slot_number(item[0]), + ) + for key, value in mapping_items: + _LOGGER.debug("%s: %s", key, value) # Combine mac mappings into a newline-separated string - mac_mappings = "\n".join(mac_mappings_list) + mac_mappings = "\n".join(value for _, value in mapping_items) _LOGGER.debug("mac_mappings: %s", mac_mappings) # Set up the network scanner entity