Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
33 changes: 33 additions & 0 deletions custom_components/network_scanner/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
131 changes: 95 additions & 36 deletions custom_components/network_scanner/config_flow.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"},
)
2 changes: 1 addition & 1 deletion custom_components/network_scanner/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
46 changes: 23 additions & 23 deletions custom_components/network_scanner/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down