diff --git a/README.md b/README.md index 0fc21ef..0648131 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Home Assistant custom component for control Huawei mesh routers over LAN. - control of the NFC (OneHop Connect) on each router separately - control of the Fast Roaming function (802.11r) - control of the Target Wake Time (reduce power consumption of Wi-Fi 6 devices in sleep mode) +- port mapping switches - reboot buttons - events for connecting, disconnecting, or moving devices over a mesh network - automatic detection of available functions @@ -51,6 +52,7 @@ Home Assistant custom component for control Huawei mesh routers over LAN. | [Huawei WiFi Mesh](https://consumer.huawei.com/en/routers/wifi-mesh/) | AC2200 | | | [Huawei WiFi Mesh 3](https://consumer.huawei.com/en/routers/wifi-mesh3/) | WS8100 | My router model. All features are available | | [Huawei WiFi Mesh 7](https://consumer.huawei.com/en/routers/wifi-mesh7/) | WS8800 | | +| [Honor Router 3](https://www.hihonor.com/global/routers/honor-router-3/) | XD20 | | Other Huawei routers based on HarmonyOS (except Chinese domestic market) most likely will work. @@ -74,7 +76,7 @@ Copy `huawei_mesh_router` folder from [latest release](https://github.com/vmakee ### HACS -`HACS` **->** press `EXPLORE & DOWNLOAD REPOSITORIES` button **->** type `huawei mesh router` **->** press `DOWNLOAD` button and follow the instructions. +`HACS` **->** type `huawei mesh router` **->** click on it **->** press `DOWNLOAD` button and follow the instructions. Or simply click the button below: @@ -111,6 +113,7 @@ Advanced settings include: | Enabling or disabling [Router-specific zones](docs/device-tracking.md#router-specific-zones) | Disabled | | Enabling or disabling [Website filtering switches](docs/controls.md#website-filtering) | Disabled | | Enabling or disabling [Event entities](docs/events.md#event-entities) | Disabled | +| Enabling or disabling [Port mapping switches](docs/controls.md#port-mapping) | Disabled | ![Options 1/2](docs/images/options_1.png) @@ -149,6 +152,7 @@ You can attach one or more tags to each client device in order to be able to use * Device Wi-Fi Access ([read more](docs/controls.md#device-wi-fi-access)) * Website filtering ([read more](docs/controls.md#website-filtering)) * Guest network ([read more](docs/controls.md#guest-network)) +* Port mapping ([read more](docs/controls.md#port-mapping)) ### Selects * Wi-Fi access control mode ([read more](docs/controls.md#wi-fi-access-control-mode)) diff --git a/custom_components/huawei_mesh_router/__init__.py b/custom_components/huawei_mesh_router/__init__.py index 61f141c..0caab98 100644 --- a/custom_components/huawei_mesh_router/__init__.py +++ b/custom_components/huawei_mesh_router/__init__.py @@ -12,6 +12,7 @@ from .client.huaweiapi import HuaweiApi from .const import ( DEFAULT_DEVICE_TRACKER_ZONES, + DEFAULT_PORT_MAPPING_SWITCHES, DEFAULT_SCAN_INTERVAL, DEFAULT_URL_FILTER_SWITCHES, DEFAULT_EVENT_ENTITIES, @@ -20,6 +21,7 @@ OPT_DEVICE_TRACKER_ZONES, OPT_DEVICES_TAGS, OPT_EVENT_ENTITIES, + OPT_PORT_MAPPING_SWITCHES, OPT_ROUTER_CLIENTS_SENSORS, OPT_URL_FILTER_SWITCHES, OPT_WIFI_ACCESS_SWITCHES, @@ -175,6 +177,11 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry): updated_options[OPT_EVENT_ENTITIES] = DEFAULT_EVENT_ENTITIES config_entry.version = 5 + if config_entry.version == 5: + _LOGGER.debug("Migrating to version 6") + updated_options[OPT_PORT_MAPPING_SWITCHES] = DEFAULT_PORT_MAPPING_SWITCHES + config_entry.version = 6 + hass.config_entries.async_update_entry( config_entry, data=updated_data, options=updated_options ) diff --git a/custom_components/huawei_mesh_router/classes.py b/custom_components/huawei_mesh_router/classes.py index dca19ed..7814fb4 100644 --- a/custom_components/huawei_mesh_router/classes.py +++ b/custom_components/huawei_mesh_router/classes.py @@ -34,6 +34,7 @@ class Select(StrEnum): class EmulatedSwitch(StrEnum): DEVICE_ACCESS = "wlan_device_access_switch" URL_FILTER = "url_filter_switch" + PORT_MAPPING = "port_mapping" # --------------------------- @@ -107,6 +108,63 @@ def devices(self) -> Iterable[HuaweiFilterItem]: return self._devices +# --------------------------- +# PortMapping +# --------------------------- +class PortMapping: + def __init__( + self, + id: str, + name: str, + enabled: bool, + host_name: str, + host_ip: str, + host_mac: str, + ) -> None: + self._id = id + self._name = name + self._enabled = enabled + self._host_name = host_name + self._host_ip = host_ip + self._host_mac = host_mac + + def update_info( + self, name: str, enabled: bool, host_name: str, host_ip: str, host_mac: str + ) -> None: + self._name = name + self._enabled = enabled + self._host_name = host_name + self._host_ip = host_ip + self._host_mac = host_mac + + def set_enabled(self, enabled: bool) -> None: + self._enabled = enabled + + @property + def id(self) -> str: + return self._id + + @property + def name(self) -> str: + return self._name + + @property + def enabled(self) -> bool: + return self._enabled + + @property + def host_name(self) -> str: + return self._host_name + + @property + def host_ip(self) -> str: + return self._host_ip + + @property + def host_mac(self) -> str: + return self._host_mac + + # --------------------------- # ConnectedDevice # --------------------------- @@ -253,9 +311,9 @@ class EventTypes(StrEnum): class HuaweiEvents: def __init__(self, hass: HomeAssistant): self._hass: HomeAssistant = hass - self._subscriptions: dict[ - CALLBACK_TYPE, tuple[HANDLER_TYPE, object | None] - ] = {} + self._subscriptions: dict[CALLBACK_TYPE, tuple[HANDLER_TYPE, object | None]] = ( + {} + ) # --------------------------- # async_subscribe_event @@ -293,7 +351,7 @@ def fire_router_added( primary_router_serial: str | None, router_mac: MAC_ADDR, router_ip: str, - router_name: str | None + router_name: str | None, ) -> None: """Fire an event when a new router is discovered.""" event_data = { @@ -311,7 +369,7 @@ def fire_router_removed( primary_router_serial: str | None, router_mac: MAC_ADDR, router_ip: str, - router_name: str | None + router_name: str | None, ) -> None: """Fire an event when a router becomes unavailable.""" event_data = { @@ -331,14 +389,14 @@ def fire_device_connected( device_ip: str, device_name: str | None, router_id: str, - router_name: str + router_name: str, ) -> None: """Fire an event when a new device is connected.""" event_data = { "type": EventTypes.DEVICE_CONNECTED, "primary_router": primary_router_serial, "device": {"ip": device_ip, "mac": device_mac, "name": device_name}, - "router": {"id": router_id, "name": router_name} + "router": {"id": router_id, "name": router_name}, } self._fire(EVENT_TYPE_DEVICE, event_data) @@ -352,14 +410,14 @@ def fire_device_disconnected( device_ip: str, device_name: str | None, router_id: str, - router_name: str + router_name: str, ) -> None: """Fire an event when a device becomes disconnected.""" event_data = { "type": EventTypes.DEVICE_DISCONNECTED, "primary_router": primary_router_serial, "device": {"ip": device_ip, "mac": device_mac, "name": device_name}, - "router": {"id": router_id, "name": router_name} + "router": {"id": router_id, "name": router_name}, } self._fire(EVENT_TYPE_DEVICE, event_data) @@ -382,6 +440,6 @@ def fire_device_changed_router( "primary_router": primary_router_serial, "device": {"ip": device_ip, "mac": device_mac, "name": device_name}, "router_from": {"id": old_router_id, "name": old_router_name}, - "router_to": {"id": actual_router_id, "name": actual_router_name} + "router_to": {"id": actual_router_id, "name": actual_router_name}, } self._fire(EVENT_TYPE_DEVICE, event_data) diff --git a/custom_components/huawei_mesh_router/client/classes.py b/custom_components/huawei_mesh_router/client/classes.py index cebd484..56650ba 100644 --- a/custom_components/huawei_mesh_router/client/classes.py +++ b/custom_components/huawei_mesh_router/client/classes.py @@ -27,6 +27,7 @@ class Feature(StrEnum): WLAN_FILTER = "feature_wlan_filter" DEVICE_TOPOLOGY = "feature_device_topology" GUEST_NETWORK = "feature_guest_network" + PORT_MAPPING = "feature_port_mapping" # --------------------------- @@ -128,6 +129,72 @@ class HuaweiUrlFilterInfo: devices: list[HuaweiFilterItem] +# --------------------------- +# HuaweiPortMappingItem +# --------------------------- +class HuaweiPortMappingItem: + def __init__( + self, + id: str, + name: str, + enabled: bool, + host_ip: str, + host_mac: MAC_ADDR, + host_name: str, + application_id: str, + ) -> None: + self._id = id + self._name = name + self._enabled = enabled + self._host_ip = host_ip + self._host_name = host_name + self._host_mac = host_mac + self._application_id = application_id + + @classmethod + def parse(cls, raw_data: dict[str, Any]) -> HuaweiPortMappingItem: + id = raw_data.get("ID") + if not id: + raise ValueError("Id can not be empty") + + raw_enabled = raw_data.get("Enable") + enabled = isinstance(raw_enabled, bool) and raw_enabled + + ip_address = raw_data.get("HostIPAddress", "") + mac_address = raw_data.get("InternalHost", "") + name = raw_data.get("Name", "") + host_name = raw_data.get("HostName", "") + application_id = raw_data.get("ApplicationID", "") + + return HuaweiPortMappingItem( + id, name, enabled, ip_address, mac_address, host_name, application_id + ) + + @property + def id(self) -> str: + return self._id + + @property + def name(self) -> str: + return self._name + + @property + def enabled(self) -> bool: + return self._enabled + + @property + def host_name(self) -> str: + return self._host_name + + @property + def host_ip(self) -> str: + return self._host_ip + + @property + def host_mac(self) -> str: + return self._host_mac + + # --------------------------- # HuaweiFilterItem # --------------------------- diff --git a/custom_components/huawei_mesh_router/client/const.py b/custom_components/huawei_mesh_router/client/const.py index e656f9a..eb61fe1 100644 --- a/custom_components/huawei_mesh_router/client/const.py +++ b/custom_components/huawei_mesh_router/client/const.py @@ -17,4 +17,5 @@ URL_WLAN_FILTER: Final = "api/ntwk/wlanfilterenhance" URL_URL_FILTER: Final = "api/ntwk/urlfilter" URL_GUEST_NETWORK: Final = "api/ntwk/guest_network?type=notshowpassall" -URL_WAN_INFO : Final = "api/ntwk/wan?type=active" \ No newline at end of file +URL_WAN_INFO: Final = "api/ntwk/wan?type=active" +URL_PORT_MAPPING: Final = "api/ntwk/portmapping" diff --git a/custom_components/huawei_mesh_router/client/huaweiapi.py b/custom_components/huawei_mesh_router/client/huaweiapi.py index 72c45c4..87199ca 100644 --- a/custom_components/huawei_mesh_router/client/huaweiapi.py +++ b/custom_components/huawei_mesh_router/client/huaweiapi.py @@ -19,6 +19,7 @@ HuaweiFilterItem, HuaweiGuestNetworkDuration, HuaweiGuestNetworkItem, + HuaweiPortMappingItem, HuaweiRouterInfo, HuaweiRsaPublicKey, HuaweiUrlFilterInfo, @@ -29,6 +30,7 @@ URL_DEVICE_TOPOLOGY, URL_GUEST_NETWORK, URL_HOST_INFO, + URL_PORT_MAPPING, URL_REBOOT, URL_REPEATER_INFO, URL_SWITCH_NFC, @@ -165,7 +167,7 @@ async def get_wan_connection_info(self) -> HuaweiConnectionInfo: connected=data.get("Status") == _STATUS_CONNECTED, address=data.get("ExternalIPAddress"), upload_rate=rate_data.get("UpBandwidth", 0), - download_rate=rate_data.get("DownBandwidth", 0) + download_rate=rate_data.get("DownBandwidth", 0), ) async def get_switch_state(self, switch: Switch) -> bool: @@ -702,6 +704,29 @@ async def set_guest_network_state( headers={"Content-Type": "application/json;charset=UTF-8;enp"}, ) + async def get_port_mappings( + self, + ) -> Iterable[HuaweiPortMappingItem]: + return [ + HuaweiPortMappingItem.parse(item) + for item in await self._core_api.get(URL_PORT_MAPPING) + ] + + async def set_port_mapping_state(self, port_mapping_id: str, enabled: bool) -> None: + port_mappings = await self._core_api.get(URL_PORT_MAPPING) + target = next( + (item for item in port_mappings if item.get("ID") == port_mapping_id) + ) + + if not target: + raise InvalidActionError(f"Unknown filter: {port_mapping_id}") + + target["Enable"] = enabled + + await self._core_api.post( + URL_PORT_MAPPING, target, extra_data={"action": "update"} + ) + async def _set_guest_network_enabled(self, enabled: bool) -> None: actual_2g, actual_5g = await self.get_guest_network_info() diff --git a/custom_components/huawei_mesh_router/client/utils.py b/custom_components/huawei_mesh_router/client/utils.py index 06968ef..f3a014f 100644 --- a/custom_components/huawei_mesh_router/client/utils.py +++ b/custom_components/huawei_mesh_router/client/utils.py @@ -5,6 +5,7 @@ from .const import ( URL_DEVICE_TOPOLOGY, URL_GUEST_NETWORK, + URL_PORT_MAPPING, URL_SWITCH_NFC, URL_SWITCH_WIFI_80211R, URL_SWITCH_WIFI_TWT, @@ -103,6 +104,12 @@ async def _is_guest_network_available(self) -> bool: data = await self._core_api.get(URL_GUEST_NETWORK) return data is not None + @log_feature(Feature.PORT_MAPPING) + @unauthorized_as_false + async def _is_port_mapping_available(self) -> bool: + data = await self._core_api.get(URL_PORT_MAPPING) + return data is not None + async def update(self) -> None: """Update the available features list.""" if await self._is_nfc_available(): @@ -126,6 +133,9 @@ async def update(self) -> None: if await self._is_guest_network_available(): self._available_features.add(Feature.GUEST_NETWORK) + if await self._is_port_mapping_available(): + self._available_features.add(Feature.PORT_MAPPING) + def is_available(self, feature: Feature) -> bool: """Return true if feature is available.""" return feature in self._available_features diff --git a/custom_components/huawei_mesh_router/config_flow.py b/custom_components/huawei_mesh_router/config_flow.py index f2008bc..8cfa5b6 100644 --- a/custom_components/huawei_mesh_router/config_flow.py +++ b/custom_components/huawei_mesh_router/config_flow.py @@ -28,6 +28,7 @@ DEFAULT_NAME, DEFAULT_PASS, DEFAULT_PORT, + DEFAULT_PORT_MAPPING_SWITCHES, DEFAULT_ROUTER_CLIENTS_SENSORS, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, @@ -40,6 +41,7 @@ OPT_DEVICE_TRACKER_ZONES, OPT_DEVICES_TAGS, OPT_EVENT_ENTITIES, + OPT_PORT_MAPPING_SWITCHES, OPT_ROUTER_CLIENTS_SENSORS, OPT_URL_FILTER_SWITCHES, OPT_WIFI_ACCESS_SWITCHES, @@ -65,7 +67,7 @@ def configured_instances(hass): class HuaweiControllerConfigFlow(ConfigFlow, domain=DOMAIN): """HuaweiControllerConfigFlow class""" - VERSION = 5 + VERSION = 6 def __init__(self): """Initialize HuaweiControllerConfigFlow.""" @@ -243,6 +245,12 @@ async def async_step_features_select(self, user_input=None) -> FlowResult: OPT_EVENT_ENTITIES, DEFAULT_EVENT_ENTITIES ), ): bool, + vol.Required( + OPT_PORT_MAPPING_SWITCHES, + default=self.options.get( + OPT_PORT_MAPPING_SWITCHES, DEFAULT_PORT_MAPPING_SWITCHES + ), + ): bool, }, ), ) diff --git a/custom_components/huawei_mesh_router/const.py b/custom_components/huawei_mesh_router/const.py index 958ecfe..68ed8c6 100644 --- a/custom_components/huawei_mesh_router/const.py +++ b/custom_components/huawei_mesh_router/const.py @@ -14,6 +14,7 @@ OPT_WIFI_ACCESS_SWITCHES = "wifi_access_switches" OPT_URL_FILTER_SWITCHES = "url_filter_switches" +OPT_PORT_MAPPING_SWITCHES = "port_mapping_switches" OPT_ROUTER_CLIENTS_SENSORS = "router_clients_sensors" OPT_DEVICES_TAGS = "devices_tags" OPT_DEVICE_TRACKER = "device_tracker" @@ -34,6 +35,7 @@ DEFAULT_DEVICE_TRACKER: Final = False DEFAULT_DEVICE_TRACKER_ZONES: Final = False DEFAULT_URL_FILTER_SWITCHES: Final = False +DEFAULT_PORT_MAPPING_SWITCHES: Final = False DEFAULT_EVENT_ENTITIES: Final = False ATTR_MANUFACTURER: Final = "Huawei" @@ -44,5 +46,5 @@ Platform.BUTTON, Platform.BINARY_SENSOR, Platform.SELECT, - Platform.EVENT + Platform.EVENT, ] diff --git a/custom_components/huawei_mesh_router/manifest.json b/custom_components/huawei_mesh_router/manifest.json index 2d0392f..6aff7ac 100644 --- a/custom_components/huawei_mesh_router/manifest.json +++ b/custom_components/huawei_mesh_router/manifest.json @@ -13,5 +13,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/vmakeev/huawei_mesh_router/issues", "requirements": ["pycryptodome>=3.12.0"], - "version": "0.9.1.1" + "version": "0.9.2" } diff --git a/custom_components/huawei_mesh_router/options.py b/custom_components/huawei_mesh_router/options.py index 911b107..b0ca7fa 100644 --- a/custom_components/huawei_mesh_router/options.py +++ b/custom_components/huawei_mesh_router/options.py @@ -7,6 +7,7 @@ DEFAULT_DEVICE_TRACKER, DEFAULT_DEVICE_TRACKER_ZONES, DEFAULT_DEVICES_TAGS, + DEFAULT_PORT_MAPPING_SWITCHES, DEFAULT_ROUTER_CLIENTS_SENSORS, DEFAULT_EVENT_ENTITIES, DEFAULT_SCAN_INTERVAL, @@ -16,6 +17,7 @@ OPT_DEVICE_TRACKER_ZONES, OPT_DEVICES_TAGS, OPT_EVENT_ENTITIES, + OPT_PORT_MAPPING_SWITCHES, OPT_ROUTER_CLIENTS_SENSORS, OPT_URL_FILTER_SWITCHES, OPT_WIFI_ACCESS_SWITCHES, @@ -77,6 +79,13 @@ def url_filter_switches(self) -> bool: self._config_entry, OPT_URL_FILTER_SWITCHES, DEFAULT_URL_FILTER_SWITCHES ) + @property + def port_mapping_switches(self) -> bool: + """Return option 'port mapping switches' value""" + return get_option( + self._config_entry, OPT_PORT_MAPPING_SWITCHES, DEFAULT_PORT_MAPPING_SWITCHES + ) + @property def router_clients_sensors(self) -> bool: """Return option 'router clients sensors' value""" diff --git a/custom_components/huawei_mesh_router/switch.py b/custom_components/huawei_mesh_router/switch.py index 0c2b7d4..594fce5 100644 --- a/custom_components/huawei_mesh_router/switch.py +++ b/custom_components/huawei_mesh_router/switch.py @@ -1,4 +1,5 @@ """Huawei router switches.""" + from __future__ import annotations from abc import ABC @@ -28,7 +29,9 @@ ActiveRoutersWatcher, ClientWirelessDevicesWatcher, HuaweiDataUpdateCoordinator, + HuaweiPortMappingsWatcher, HuaweiUrlFiltersWatcher, + PortMapping, UrlFilter, ) @@ -52,6 +55,9 @@ _FUNCTION_DISPLAYED_NAME_URL_FILTER: Final = "URL filter" _FUNCTION_ID_URL_FILTER: Final = "switch_url_filter" +_FUNCTION_DISPLAYED_NAME_PORT_MAPPING: Final = "Port mapping" +_FUNCTION_ID_PORT_MAPPING: Final = "switch_port_mapping" + _FUNCTION_DISPLAYED_NAME_GUEST_NETWORK: Final = "Guest network" _FUNCTION_ID_GUEST_NETWORK: Final = "switch_guest_network" @@ -114,6 +120,24 @@ async def _add_url_filter_switch_if_available( _LOGGER.debug("Feature '%s' is not supported", Feature.URL_FILTER) +# --------------------------- +# _add_port_mapping_switch_if_available +# --------------------------- +async def _add_port_mapping_switch_if_available( + coordinator: HuaweiDataUpdateCoordinator, + known_port_mapping_switches: dict[str, HuaweiSwitch], + port_mapping: PortMapping, + async_add_entities: AddEntitiesCallback, +) -> None: + if await coordinator.is_feature_available(Feature.PORT_MAPPING): + if not known_port_mapping_switches.get(port_mapping.id): + entity = HuaweiPortMappingSwitch(coordinator, port_mapping) + async_add_entities([entity]) + known_port_mapping_switches[port_mapping.id] = entity + else: + _LOGGER.debug("Feature '%s' is not supported", Feature.PORT_MAPPING) + + # --------------------------- # async_setup_entry # --------------------------- @@ -158,6 +182,7 @@ async def async_setup_entry( watch_for_additional_routers(coordinator, config_entry, async_add_entities) watch_for_url_filters(coordinator, config_entry, async_add_entities) + watch_for_port_mappings(coordinator, config_entry, async_add_entities) # --------------------------- @@ -213,6 +238,9 @@ def coordinator_updated() -> None: coordinator_updated() +# --------------------------- +# watch_for_url_filters +# --------------------------- def watch_for_url_filters(coordinator, config_entry, async_add_entities): integration_options = HuaweiIntegrationOptions(config_entry) is_url_filter_switches_enabled = integration_options.url_filter_switches @@ -264,6 +292,64 @@ def coordinator_updated() -> None: coordinator_updated() +# --------------------------- +# watch_for_port_mappings +# --------------------------- +def watch_for_port_mappings(coordinator, config_entry, async_add_entities): + integration_options = HuaweiIntegrationOptions(config_entry) + is_port_mapping_switches_enabled = integration_options.port_mapping_switches + + known_port_mapping_switches: dict[str, HuaweiSwitch] = {} + port_mappings_watcher: HuaweiPortMappingsWatcher = HuaweiPortMappingsWatcher( + coordinator + ) + + @callback + def on_port_mapping_added(port_mapping_id: str, port_mapping: PortMapping) -> None: + """When a new port mapping is found.""" + coordinator.hass.async_add_job( + _add_port_mapping_switch_if_available( + coordinator, + known_port_mapping_switches, + port_mapping, + async_add_entities, + ) + ) + + @callback + def on_port_mapping_removed( + er: EntityRegistry, port_mapping_id: str, port_mapping: PortMapping + ) -> None: + """When a known port mapping removed.""" + unique_id = generate_entity_unique_id( + coordinator, _FUNCTION_ID_PORT_MAPPING, port_mapping_id + ) + entity_id = er.async_get_entity_id(Platform.SWITCH, DOMAIN, unique_id) + if entity_id: + er.async_remove(entity_id) + if port_mapping_id in known_port_mapping_switches: + del known_port_mapping_switches[port_mapping_id] + else: + _LOGGER.warning( + "Can not remove unavailable switch '%s': entity id not found.", + unique_id, + ) + + @callback + def coordinator_updated() -> None: + """Update the status of the device.""" + if is_port_mapping_switches_enabled: + port_mappings_watcher.look_for_changes( + on_port_mapping_added, on_port_mapping_removed + ) + + if is_port_mapping_switches_enabled: + config_entry.async_on_unload( + coordinator.async_add_listener(coordinator_updated) + ) + coordinator_updated() + + # --------------------------- # HuaweiSwitch # --------------------------- @@ -560,3 +646,59 @@ def _handle_coordinator_update(self) -> None: def available(self) -> bool: """Return if entity is available.""" return self.coordinator.is_router_online() and self.is_on is not None + + +# --------------------------- +# HuaweiPortMappingSwitch +# --------------------------- +class HuaweiPortMappingSwitch(HuaweiSwitch): + def __init__( + self, + coordinator: HuaweiDataUpdateCoordinator, + port_mapping: PortMapping, + ) -> None: + """Initialize.""" + self._port_mapping = port_mapping + self._attr_extra_state_attributes = {} + super().__init__( + coordinator, EmulatedSwitch.PORT_MAPPING, switch_id=port_mapping.id + ) + self._attr_device_info = None + + self._attr_name = ( + f"{_FUNCTION_DISPLAYED_NAME_PORT_MAPPING}: {port_mapping.name}" + ) + + self._attr_unique_id = generate_entity_unique_id( + coordinator, _FUNCTION_ID_PORT_MAPPING, port_mapping.id + ) + self.entity_id = generate_entity_id( + coordinator, + ENTITY_DOMAIN, + _FUNCTION_DISPLAYED_NAME_PORT_MAPPING, + port_mapping.name, + ) + self._attr_icon = "mdi:upload-network-outline" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + debug_data = f"Id: {self._port_mapping.id}, name: {self._port_mapping.name}, enabled: {self._port_mapping.enabled}" + + _LOGGER.debug("Switch %s: info is %s", self._switch_id, debug_data) + + self._attr_name = ( + f"{_FUNCTION_DISPLAYED_NAME_PORT_MAPPING}: {self._port_mapping.name}" + ) + + self._attr_extra_state_attributes["host_name"] = self._port_mapping.host_name + self._attr_extra_state_attributes["host_ip"] = self._port_mapping.host_ip + self._attr_extra_state_attributes["host_mac"] = self._port_mapping.host_mac + + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.is_router_online() and self.is_on is not None diff --git a/custom_components/huawei_mesh_router/translations/en.json b/custom_components/huawei_mesh_router/translations/en.json index 60b8732..6864b4c 100644 --- a/custom_components/huawei_mesh_router/translations/en.json +++ b/custom_components/huawei_mesh_router/translations/en.json @@ -38,6 +38,7 @@ "devices_tags": "Device tags", "device_tracker": "Devices tracking", "device_tracker_zones": "Router-specific zones for tracked devices", + "port_mapping_switches": "Port forwarding switches", "url_filter_switches": "Website filtering switches", "event_entities": "Event entities" }, diff --git a/custom_components/huawei_mesh_router/translations/es.json b/custom_components/huawei_mesh_router/translations/es.json index 1d479ce..a46cc27 100644 --- a/custom_components/huawei_mesh_router/translations/es.json +++ b/custom_components/huawei_mesh_router/translations/es.json @@ -38,6 +38,7 @@ "devices_tags": "Tags de dispositivos", "device_tracker": "Rastreador de dispositivos", "device_tracker_zones": "Zonas para rastrear dispositivos por router", + "port_mapping_switches": "Switches para mapeo de puertos", "url_filter_switches": "Switches para filtrado de Sitios Web", "event_entities": "Entidades de eventos" }, diff --git a/custom_components/huawei_mesh_router/translations/pt-BR.json b/custom_components/huawei_mesh_router/translations/pt-BR.json index 0ce0686..6b44311 100644 --- a/custom_components/huawei_mesh_router/translations/pt-BR.json +++ b/custom_components/huawei_mesh_router/translations/pt-BR.json @@ -38,6 +38,7 @@ "devices_tags": "Tags do dispositivo", "device_tracker": "Rastreamento de dispositivos", "device_tracker_zones": "Zonas específicas do roteador para dispositivos rastreados", + "port_mapping_switches": "Interruptores de encaminhamento de porta", "url_filter_switches": "Interruptores de filtragem de sites", "event_entities": "Entidades de eventos" }, diff --git a/custom_components/huawei_mesh_router/translations/ru.json b/custom_components/huawei_mesh_router/translations/ru.json index ca26360..741dfc3 100644 --- a/custom_components/huawei_mesh_router/translations/ru.json +++ b/custom_components/huawei_mesh_router/translations/ru.json @@ -38,6 +38,7 @@ "devices_tags": "Теги клиентских устройств", "device_tracker": "Отслеживание устройств", "device_tracker_zones": "Отдельные зоны для роутеров при отслеживании устройств", + "port_mapping_switches": "Выключатели переадресации портов", "url_filter_switches": "Выключатели фильтров web-сайтов", "event_entities": "Объекты для событий" }, diff --git a/custom_components/huawei_mesh_router/translations/sk.json b/custom_components/huawei_mesh_router/translations/sk.json index d98d359..a38e43e 100644 --- a/custom_components/huawei_mesh_router/translations/sk.json +++ b/custom_components/huawei_mesh_router/translations/sk.json @@ -38,6 +38,7 @@ "devices_tags": "Štítky zariadení", "device_tracker": "Sledovanie zariadení", "device_tracker_zones": "Zóny špecifické pre smerovač pre sledované zariadenia", + "port_mapping_switches": "Prepínače pre presmerovanie portov", "url_filter_switches": "Prepínače filtrovania webových stránok", "event_entities": "Subjekty udalostí" }, diff --git a/custom_components/huawei_mesh_router/update_coordinator.py b/custom_components/huawei_mesh_router/update_coordinator.py index 286e9dd..1e49728 100644 --- a/custom_components/huawei_mesh_router/update_coordinator.py +++ b/custom_components/huawei_mesh_router/update_coordinator.py @@ -1,4 +1,5 @@ """Huawei Controller for Huawei Router.""" + from __future__ import annotations from datetime import timedelta @@ -30,6 +31,7 @@ HuaweiEvents, HuaweiInterfaceType, HuaweiWlanFilterMode, + PortMapping, Select, UrlFilter, ZoneInfo, @@ -53,7 +55,14 @@ from .client.huaweiapi import HuaweiApi from .const import ATTR_MANUFACTURER, DOMAIN from .options import HuaweiIntegrationOptions -from .utils import HuaweiChangesWatcher, TagsMap, ZonesMap, _TItem, _TKey, get_readable_rate +from .utils import ( + HuaweiChangesWatcher, + TagsMap, + ZonesMap, + _TItem, + _TKey, + get_readable_rate, +) _PRIMARY_ROUTER_IDENTITY: Final = "primary_router" @@ -101,6 +110,38 @@ def look_for_changes( on_removed(er, key, item) +# --------------------------- +# HuaweiPortMappingsWatcher +# --------------------------- +class HuaweiPortMappingsWatcher(HuaweiChangesWatcher[str, PortMapping]): + def _get_actual_items(self) -> Iterable[_TItem]: + return self._coordinator.port_mappings.values() + + def _get_key(self, item: _TItem) -> _TKey: + return item.id + + def __init__(self, coordinator: HuaweiDataUpdateCoordinator) -> None: + """Initialize.""" + self._coordinator = coordinator + super().__init__(lambda item: True) + + def look_for_changes( + self, + on_added: Callable[[str, PortMapping], None] | None = None, + on_removed: Callable[[EntityRegistry, str, PortMapping], None] | None = None, + ) -> None: + """Look for difference between previously known and current lists of items.""" + added, removed = self._get_difference(self._coordinator.hass) + + if on_added: + for key, item in added: + on_added(key, item) + + if on_removed: + for er, key, item in removed: + on_removed(er, key, item) + + # --------------------------- # HuaweiConnectedDevicesWatcher # --------------------------- @@ -123,8 +164,9 @@ def __init__( def look_for_changes( self, on_added: Callable[[MAC_ADDR, ConnectedDevice], None] | None = None, - on_removed: Callable[[EntityRegistry, MAC_ADDR, ConnectedDevice], None] - | None = None, + on_removed: ( + Callable[[EntityRegistry, MAC_ADDR, ConnectedDevice], None] | None + ) = None, ) -> None: """Look for difference between previously known and current lists of items.""" added, removed = self._get_difference(self._coordinator.hass) @@ -261,6 +303,7 @@ def __init__( self._select_states: dict[Select | str, str] = {} self._wlan_filter_info: HuaweiFilterInfo | None = None self._url_filters: dict[str, UrlFilter] = {} + self._port_mappings: dict[str, PortMapping] = {} super().__init__( hass, @@ -305,6 +348,11 @@ def url_filters(self) -> dict[str, UrlFilter]: """Return the url filters.""" return self._url_filters + @property + def port_mappings(self) -> dict[str, PortMapping]: + """Return the url filters.""" + return self._port_mappings + @property def tags_map(self) -> TagsMap: """Return the tags map.""" @@ -400,11 +448,59 @@ async def async_update(self) -> None: await self._update_router_infos() await self._update_wan_info() await self._update_url_filter_info() + await self._update_port_mappings() await self._update_switches() await self._update_selects() self._logger.debug("Update completed") self._is_initial_update = False + @suppress_update_exception("Can not update port mappings %s") + async def _update_port_mappings(self) -> None: + + if not self._integration_options.port_mapping_switches: + return + + if not await self.primary_router_api.is_feature_available(Feature.PORT_MAPPING): + return + + self._logger.debug("Updating port mappings") + + new_port_mappings = { + item.id: item for item in await self.primary_router_api.get_port_mappings() + } + + missing_port_mapping_ids: list[str] = [] + for existing_port_mapping in self._port_mappings.values(): + updated_port_mapping = new_port_mappings.get(existing_port_mapping.id) + if not updated_port_mapping: + missing_port_mapping_ids.append(existing_port_mapping.id) + continue + + existing_port_mapping.update_info( + name=updated_port_mapping.name, + enabled=updated_port_mapping.enabled, + host_name=updated_port_mapping.host_name, + host_ip=updated_port_mapping.host_ip, + host_mac=updated_port_mapping.host_mac, + ) + + if missing_port_mapping_ids: + for missing_id in missing_port_mapping_ids: + del self._port_mappings[missing_id] + + for updated_port_mapping in new_port_mappings.values(): + if updated_port_mapping.id not in self._port_mappings: + self._port_mappings[updated_port_mapping.id] = PortMapping( + updated_port_mapping.id, + updated_port_mapping.name, + updated_port_mapping.enabled, + updated_port_mapping.host_name, + updated_port_mapping.host_ip, + updated_port_mapping.host_mac, + ) + + self._logger.debug("Port mappings updated") + @suppress_update_exception("Can not update repeater state %s") async def _update_repeater_state(self) -> None: self._logger.debug("Updating repeater state") @@ -622,7 +718,7 @@ async def _update_switches(self) -> None: primary_api = self._select_api(_PRIMARY_ROUTER_IDENTITY) - new_states: dict[Switch | str, bool] = {} + new_states: dict[Switch | EmulatedSwitch | str, bool] = {} if await primary_api.is_feature_available(Feature.WIFI_80211R): state = await primary_api.get_switch_state(Switch.WIFI_80211R) @@ -656,11 +752,9 @@ async def _update_switches(self) -> None: if device and device.is_active: await self.update_router_nfc_switch(api, device, new_states) - if self._integration_options.wifi_access_switches: - await self.calculate_device_access_switch_states(new_states) - - if self._integration_options.url_filter_switches: - await self.calculate_url_filter_switch_states(new_states) + await self.calculate_device_access_switch_states(new_states) + await self.calculate_url_filter_switch_states(new_states) + await self.calculate_port_mapping_switches(new_states) self._switch_states = new_states self._logger.debug("Switches states updated") @@ -681,7 +775,7 @@ async def update_router_nfc_switch( ) async def calculate_device_access_switch_states( - self, states: dict[Switch | str, bool] | None = None + self, states: dict[Switch | EmulatedSwitch | str, bool] | None = None ) -> None: """Update device access switch states.""" if not self._integration_options.wifi_access_switches: @@ -709,7 +803,7 @@ async def calculate_device_access_switch_states( ) async def calculate_url_filter_switch_states( - self, states: dict[Switch | str, bool] | None = None + self, states: dict[Switch | EmulatedSwitch | str, bool] | None = None ) -> None: """Update url filter switch states.""" if not self._integration_options.url_filter_switches: @@ -726,6 +820,24 @@ async def calculate_url_filter_switch_states( item.enabled, ) + async def calculate_port_mapping_switches( + self, states: dict[Switch | EmulatedSwitch | str, bool] | None = None + ) -> None: + """Update url filter switch states.""" + if not self._integration_options.port_mapping_switches: + return + + states = states or self._switch_states + + if await self.primary_router_api.is_feature_available(Feature.PORT_MAPPING): + for item in self._port_mappings.values(): + states[f"{EmulatedSwitch.PORT_MAPPING}_{item.id}"] = item.enabled + self._logger.debug( + "Port mapping switch (%s) state updated to %s", + item.id, + item.enabled, + ) + @suppress_update_exception("Can not update connected devices: %s") async def _update_connected_devices(self) -> None: """Asynchronous update of connected devices.""" @@ -780,13 +892,13 @@ def get_mesh_routers( devices_to_filters: dict[MAC_ADDR, HuaweiWlanFilterMode] = {} if self._wlan_filter_info: for blacklisted in self._wlan_filter_info.blacklist: - devices_to_filters[ - blacklisted.mac_address - ] = HuaweiWlanFilterMode.BLACKLIST + devices_to_filters[blacklisted.mac_address] = ( + HuaweiWlanFilterMode.BLACKLIST + ) for whitelisted in self._wlan_filter_info.whitelist: - devices_to_filters[ - whitelisted.mac_address - ] = HuaweiWlanFilterMode.WHITELIST + devices_to_filters[whitelisted.mac_address] = ( + HuaweiWlanFilterMode.WHITELIST + ) if self._integration_options.devices_tags and not self._tags_map.is_loaded: await self._tags_map.load() @@ -826,7 +938,7 @@ def get_mesh_routers( ip_address, name, connected_via.get("id"), - connected_via.get("name") + connected_via.get("name"), ) # if state of the device is changed then firing an event @@ -838,7 +950,7 @@ def get_mesh_routers( ip_address, name, connected_via.get("id"), - connected_via.get("name") + connected_via.get("name"), ) else: self._events.fire_device_disconnected( @@ -847,7 +959,7 @@ def get_mesh_routers( ip_address, name, device.connected_via_id, - device.connected_via_name + device.connected_via_name, ) if is_active: @@ -905,7 +1017,9 @@ def get_mesh_routers( is_hilink=device_data.is_hilink, is_router=device_data.is_router, connected_via_id=connected_via.get("id"), - zone=ZoneInfo(name=zone_name, entity_id=zone_id) if zone_id else None, + zone=( + ZoneInfo(name=zone_name, entity_id=zone_id) if zone_id else None + ), upload_rate_kilobytes_s=device_data.upload_rate, download_rate_kilobytes_s=device_data.download_rate, upload_rate=get_readable_rate(device_data.upload_rate), @@ -989,6 +1103,22 @@ async def set_switch_state( await self.primary_router_api.apply_url_filter_info(filter_info) + # Port mapping switch processing + elif switch == EmulatedSwitch.PORT_MAPPING: + if not switch_id: + raise CoordinatorError( + f"Can not set value: switch_id is required for {EmulatedSwitch.PORT_MAPPING}" + ) + port_mapping = self._port_mappings.get(switch_id) + if port_mapping.enabled == state: + return + + port_mapping.set_enabled(state) + + await self.primary_router_api.set_port_mapping_state( + port_mapping.id, port_mapping.enabled + ) + # other switches else: api = self._select_api(device_mac) diff --git a/docs/controls.md b/docs/controls.md index f605fb4..684483e 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -125,6 +125,34 @@ _Note: these switches are not attached to a specific router device._ _Note: if the filter is removed in the Primary router, the corresponding switch will be removed from the Home Assistant._ +### Port mapping + +Allows you to enable or disable port forwarding. + +![Port mapping switch](images/switch_port_mapping.png) + +One switch is created for each forwarded port in the router: +* `switch._port_mapping_` + +Each switch exposes the following attributes: + +| Attribute | Description | +|------------------|------------------------------------------------------------------| +| `host_name` | Displayed name of the device to which the port will be forwarded | +| `host_ip` | IP address of the device to which the port will be forwarded | +| `host_mac` | MAC address of the device to which the port will be forwarded | + +You can configure port forwarding in the web interface of your router. +Example address: `http://192.168.3.1/html/index.html#/more/nat` + +![Port forwarding setup](images/nat_port_forwarding.png) + +These switches will not be added to Home Assistant if the Primary router does not support port forwarding, or this feature is not enabled in [advanced options](../README.md#advanced-options). + +_Note: these switches are not attached to a specific router device._ + +_Note: if the port forwarding is removed in the Primary router, the corresponding switch will be removed from the Home Assistant._ + ### Guest network Allows you to enable or disable the guest Wi-Fi network. diff --git a/docs/images/nat_port_forwarding.png b/docs/images/nat_port_forwarding.png new file mode 100644 index 0000000..acf4a99 Binary files /dev/null and b/docs/images/nat_port_forwarding.png differ diff --git a/docs/images/options_2.png b/docs/images/options_2.png index 4b69df4..3fe6aa9 100644 Binary files a/docs/images/options_2.png and b/docs/images/options_2.png differ diff --git a/docs/images/switch_port_mapping.png b/docs/images/switch_port_mapping.png new file mode 100644 index 0000000..6ebe661 Binary files /dev/null and b/docs/images/switch_port_mapping.png differ