diff --git a/README.md b/README.md index cfe1fba..49322eb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,68 @@ Custom component to allow control of Eero networks in [Home Assistant](https://h - Set blocked apps for profiles (requires Eero Plus subscription) - Update entities for Eero device firmware management - Control backup networks (requires Eero Plus subscription) +- Read and manage DHCP reservations (static IPs) for clients + +## DHCP Reservations +Client `device_tracker` entities expose whether their current IP is a static DHCP reservation: +- `ip_reserved` (bool) — whether the client's IP is reserved +- `reserved_ip` — the reserved IP address, when one exists + +Two services create or remove reservations (pin an IP to a MAC address): + +**`eero.set_reservation`** — create or update a reservation +| Field | Required | Description | +| --- | --- | --- | +| `mac` | yes | MAC address of the device | +| `ip` | yes | IP address to assign | +| `name` | no | Description shown for the reservation | +| `target_network` | no | Network name(s)/ID(s); defaults to all | + +**`eero.delete_reservation`** — remove a reservation +| Field | Required | Description | +| --- | --- | --- | +| `mac` | yes | MAC address of the device | +| `target_network` | no | Network name(s)/ID(s); defaults to all | + +### Example: one-tap "reserve" button on a dashboard +Because each `device_tracker` exposes its live `mac` and `ip`, you can pin a device to +its **current** IP straight from a dashboard button. A small script reads those +attributes and toggles the reservation: + +```yaml +# scripts.yaml +eero_toggle_reservation: + alias: "eero: toggle reservation" + fields: + tracker: + description: The eero device_tracker to reserve/unreserve + sequence: + - choose: + - conditions: "{{ state_attr(tracker, 'ip_reserved') | default(false) }}" + sequence: + - service: eero.delete_reservation + data: + mac: "{{ state_attr(tracker, 'mac') }}" + - conditions: "{{ (state_attr(tracker, 'ip') | default('')) not in ['', 'None', none] }}" + sequence: + - service: eero.set_reservation + data: + mac: "{{ state_attr(tracker, 'mac') }}" + ip: "{{ state_attr(tracker, 'ip') }}" + name: "{{ state_attr(tracker, 'host_name') or tracker }}" +``` + +Any button can then call it with a device's entity, e.g. a `tap_action`: + +```yaml +tap_action: + action: perform-action + perform_action: script.eero_toggle_reservation + data: + tracker: device_tracker.my_device +``` + +Tapping once reserves the device at its current IP; tapping again releases it. ## Coming Soon - TBD, feature requests are welcome. diff --git a/custom_components/eero/__init__.py b/custom_components/eero/__init__.py index aff8889..97dc2a3 100755 --- a/custom_components/eero/__init__.py +++ b/custom_components/eero/__init__.py @@ -97,6 +97,33 @@ } ) +# DHCP reservation services +ATTR_MAC = "mac" +ATTR_IP = "ip" +ATTR_NAME = "name" +SERVICE_SET_RESERVATION = "set_reservation" +SERVICE_DELETE_RESERVATION = "delete_reservation" + +SET_RESERVATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_MAC): cv.string, + vol.Required(ATTR_IP): cv.string, + vol.Optional(ATTR_NAME, default=""): cv.string, + vol.Optional(ATTR_TARGET_NETWORK, default=[]): vol.All( + cv.ensure_list, [vol.Any(cv.positive_int, cv.string)] + ), + } +) + +DELETE_RESERVATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_MAC): cv.string, + vol.Optional(ATTR_TARGET_NETWORK, default=[]): vol.All( + cv.ensure_list, [vol.Any(cv.positive_int, cv.string)] + ), + } +) + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -476,6 +503,26 @@ async def async_set_blocked_apps(service): ) await coordinator.async_request_refresh() + async def async_set_reservation(service): + mac = service.data[ATTR_MAC] + ip = service.data[ATTR_IP] + name = service.data[ATTR_NAME] + for network in _validate_network( + target_network=service.data[ATTR_TARGET_NETWORK] + ): + await hass.async_add_executor_job( + network.create_reservation, mac, ip, name + ) + await coordinator.async_request_refresh() + + async def async_delete_reservation(service): + mac = service.data[ATTR_MAC] + for network in _validate_network( + target_network=service.data[ATTR_TARGET_NETWORK] + ): + await hass.async_add_executor_job(network.delete_reservation, mac) + await coordinator.async_request_refresh() + def _validate_network(target_network: str): return [ network @@ -517,6 +564,20 @@ def _validate_profile(target_profile: str, target_network: str): schema=SET_BLOCKED_APPS_SCHEMA, ) + # Reservation services (always available) + hass.services.async_register( + DOMAIN, + SERVICE_SET_RESERVATION, + async_set_reservation, + schema=SET_RESERVATION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_RESERVATION, + async_delete_reservation, + schema=DELETE_RESERVATION_SCHEMA, + ) + for network in coordinator.data.networks: if network.id in conf_networks: device_registry.async_get_or_create( diff --git a/custom_components/eero/api/__init__.py b/custom_components/eero/api/__init__.py index d44a735..0534e67 100755 --- a/custom_components/eero/api/__init__.py +++ b/custom_components/eero/api/__init__.py @@ -353,6 +353,18 @@ def update( network_data, "devices" ) + # DHCP reservations + # Defensive: never let this break the integration load. + try: + network_data["reservations"] = self.get_reservations( + network_data + ) + except Exception as exception: # noqa: BLE001 + _LOGGER.warning( + "eero reservations: fetch failed (%s)", exception + ) + network_data["reservations"] = {"data": []} + if any( [ not config, @@ -426,6 +438,33 @@ def get_resource_data( "data": resource_data, } + def get_reservations(self, network_data: dict) -> dict: + """Get DHCP reservations for the network. + + eero exposes reservations either as a named resource URL or at the + conventional ``{network_url}/reservations`` path. Try the resource map + first, then fall back. The raw response is logged once at DEBUG so the + exact shape can be confirmed on a real network. + """ + resources = network_data.get("resources", {}) + url = resources.get("reservations") + if not url: + network_url = network_data.get("url") + if network_url: + url = f"{network_url}/reservations" + if not url: + return {"data": []} + data = self.call(method=METHOD_GET, url=url) + _LOGGER.debug("eero reservations raw response from %s: %s", url, data) + # Normalise: accept a bare list, or {"data": [...]}, or {"reservations": [...]} + if isinstance(data, dict): + items = data.get("data") or data.get("reservations") or [] + elif isinstance(data, list): + items = data + else: + items = [] + return {"count": len(items), "data": items} + def update_activity( self, activity: str, diff --git a/custom_components/eero/api/client.py b/custom_components/eero/api/client.py index 939f077..34cec34 100755 --- a/custom_components/eero/api/client.py +++ b/custom_components/eero/api/client.py @@ -219,6 +219,24 @@ def ip(self) -> str | None: """IP.""" return self.data.get("ip") + @property + def reservation(self) -> dict | None: + """Matching DHCP reservation for this client, if any.""" + return self.network.get_reservation(self.mac) + + @property + def is_reserved(self) -> bool: + """Whether this client's IP is a DHCP reservation.""" + return self.reservation is not None + + @property + def reserved_ip(self) -> str | None: + """The reserved IP address, if this client has a reservation.""" + reservation = self.reservation + if reservation: + return reservation.get("ip") or reservation.get("ip_address") + return None + @property def is_guest(self) -> bool | None: """Is guest.""" diff --git a/custom_components/eero/api/network.py b/custom_components/eero/api/network.py index 8dc1ad3..f1fb329 100755 --- a/custom_components/eero/api/network.py +++ b/custom_components/eero/api/network.py @@ -917,6 +917,50 @@ def clients(self) -> list[EeroClient | None]: for client in self.data.get("devices", {}).get("data", []) ] + @property + def reservations(self) -> list[dict]: + """DHCP reservations.""" + return self.data.get("reservations", {}).get("data", []) + + def get_reservation(self, mac: str | None) -> dict | None: + """Return the reservation dict matching a client MAC, if any.""" + if not mac: + return None + target = mac.lower().replace("-", ":") + for reservation in self.reservations: + r_mac = reservation.get("mac") or reservation.get("mac_address") + if r_mac and r_mac.lower().replace("-", ":") == target: + return reservation + return None + + def create_reservation( + self, mac: str, ip: str, description: str = "" + ) -> dict | None: + """Create (or update) a DHCP reservation. + + POSTs to the network's reservations collection. eero keys the + reservation on the MAC, so re-POSTing an existing MAC updates its IP. + """ + return self.api.call( + method=METHOD_POST, + url=f"{self.url}/reservations", + json={"mac": mac, "ip": ip, "description": description}, + ) + + def delete_reservation(self, mac: str) -> dict | None: + """Delete the DHCP reservation matching a MAC.""" + reservation = self.get_reservation(mac) + if not reservation: + return None + url = reservation.get("url") + if not url: + reservation_id = reservation.get("id") + if reservation_id is not None: + url = f"{self.url}/reservations/{reservation_id}" + if not url: + return None + return self.api.call(method=METHOD_DELETE, url=url) + @property def eeros(self) -> list[EeroDevice | EeroDeviceBeacon | None]: """Eeros.""" diff --git a/custom_components/eero/device_tracker.py b/custom_components/eero/device_tracker.py index 1cc2766..01bff82 100755 --- a/custom_components/eero/device_tracker.py +++ b/custom_components/eero/device_tracker.py @@ -210,4 +210,12 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: if manufacturer := self.resource.manufacturer: attrs[ATTR_MANUFACTURER] = manufacturer attrs["network_name"] = self.network.name + # DHCP reservation status (shown regardless of connection) + if self.resource.is_client: + try: + attrs["ip_reserved"] = self.resource.is_reserved + if reserved_ip := self.resource.reserved_ip: + attrs["reserved_ip"] = reserved_ip + except Exception: # noqa: BLE001 + pass return attrs diff --git a/custom_components/eero/services.yaml b/custom_components/eero/services.yaml index bc3f656..592bed1 100755 --- a/custom_components/eero/services.yaml +++ b/custom_components/eero/services.yaml @@ -9,4 +9,32 @@ set_blocked_apps: example: John Doe or 1234567 target_network: required: False - example: My Network or 1234567 + example: My Network or 1234567 + +set_reservation: + name: Set DHCP reservation + description: Create or update a static DHCP reservation (pin an IP to a MAC). + fields: + mac: + required: True + example: "e2:0f:c3:f5:2c:85" + ip: + required: True + example: "192.168.4.65" + name: + required: False + example: "Chris iPhone" + target_network: + required: False + example: My Network or 1234567 + +delete_reservation: + name: Delete DHCP reservation + description: Remove the static DHCP reservation for a MAC. + fields: + mac: + required: True + example: "e2:0f:c3:f5:2c:85" + target_network: + required: False + example: My Network or 1234567 diff --git a/custom_components/eero/strings.json b/custom_components/eero/strings.json index f064938..c58e1f2 100755 --- a/custom_components/eero/strings.json +++ b/custom_components/eero/strings.json @@ -146,6 +146,42 @@ "description": "(Optional) Name(s) or ID(s) of network(s) in which desired profile(s) are located. Defaults to all networks if not specified." } } + }, + "set_reservation": { + "name": "Set DHCP reservation", + "description": "Create or update a static DHCP reservation, pinning an IP address to a device's MAC address.", + "fields": { + "mac": { + "name": "MAC address", + "description": "(Required) MAC address of the device to reserve." + }, + "ip": { + "name": "IP address", + "description": "(Required) IP address to assign to the device." + }, + "name": { + "name": "Name", + "description": "(Optional) Description shown for the reservation." + }, + "target_network": { + "name": "Target network(s)", + "description": "(Optional) Name(s) or ID(s) of network(s). Defaults to all networks if not specified." + } + } + }, + "delete_reservation": { + "name": "Delete DHCP reservation", + "description": "Remove the static DHCP reservation for a device's MAC address.", + "fields": { + "mac": { + "name": "MAC address", + "description": "(Required) MAC address of the device whose reservation should be removed." + }, + "target_network": { + "name": "Target network(s)", + "description": "(Optional) Name(s) or ID(s) of network(s). Defaults to all networks if not specified." + } + } } }, "selector": { diff --git a/custom_components/eero/translations/en.json b/custom_components/eero/translations/en.json index f064938..c58e1f2 100755 --- a/custom_components/eero/translations/en.json +++ b/custom_components/eero/translations/en.json @@ -146,6 +146,42 @@ "description": "(Optional) Name(s) or ID(s) of network(s) in which desired profile(s) are located. Defaults to all networks if not specified." } } + }, + "set_reservation": { + "name": "Set DHCP reservation", + "description": "Create or update a static DHCP reservation, pinning an IP address to a device's MAC address.", + "fields": { + "mac": { + "name": "MAC address", + "description": "(Required) MAC address of the device to reserve." + }, + "ip": { + "name": "IP address", + "description": "(Required) IP address to assign to the device." + }, + "name": { + "name": "Name", + "description": "(Optional) Description shown for the reservation." + }, + "target_network": { + "name": "Target network(s)", + "description": "(Optional) Name(s) or ID(s) of network(s). Defaults to all networks if not specified." + } + } + }, + "delete_reservation": { + "name": "Delete DHCP reservation", + "description": "Remove the static DHCP reservation for a device's MAC address.", + "fields": { + "mac": { + "name": "MAC address", + "description": "(Required) MAC address of the device whose reservation should be removed." + }, + "target_network": { + "name": "Target network(s)", + "description": "(Optional) Name(s) or ID(s) of network(s). Defaults to all networks if not specified." + } + } } }, "selector": {