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
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
61 changes: 61 additions & 0 deletions custom_components/eero/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
39 changes: 39 additions & 0 deletions custom_components/eero/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions custom_components/eero/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
44 changes: 44 additions & 0 deletions custom_components/eero/api/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
8 changes: 8 additions & 0 deletions custom_components/eero/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 29 additions & 1 deletion custom_components/eero/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 36 additions & 0 deletions custom_components/eero/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading