diff --git a/custom_components/itag/README.md b/custom_components/itag/README.md index 393f5c8..bfc054e 100644 --- a/custom_components/itag/README.md +++ b/custom_components/itag/README.md @@ -10,7 +10,8 @@ with `active: true` connections. No YAML required. |---|---| | `event._button` | The physical button (`ffe0/ffe1` notify) — also fired on the bus as `itag_button` with `address`, `name`, `room` | | `button._find` / `button._stop_ringing` | Beep / silence (Immediate Alert `1802/2a06`) | -| `sensor._room` | Room = the proxy holding the GATT link (or the freshest/strongest scanner while disconnected) | +| `device_tracker.` | `home` while any proxy hears/holds the tag, `not_home` otherwise — add it to a **person** to track home/away | +| `sensor._room` | Room = the **HA area** of the proxy holding the GATT link (or of the freshest/strongest scanner while disconnected); falls back to parsing the proxy's name | | `sensor._signal` | Last advert RSSI (diagnostic) | | `sensor._battery` | GATT `180F` battery — only some iTAG variants expose it; stays unavailable otherwise | | `binary_sensor._connected` / `_ringing` | Link + beep state | @@ -30,5 +31,8 @@ Services: `itag.find` / `itag.stop` (optional `device_id`). reconnect — press **Rescan room** after moving it, or use the distributed [ESPHome contested-claiming fleet](../../esphome/) for automatic multi-room handoff. +- `not_home` needs *no* proxy to hear the tag, so leaving takes up to a coordinator + tick plus HA's stale-advertisement window (~2 min worst case). Arrival is + immediate — the first advertisement flips it back to `home`. - iTAG MACs are usually random-static: they can **rotate after a battery swap**. If a tag goes permanently unavailable after new batteries, re-add it. diff --git a/custom_components/itag/__init__.py b/custom_components/itag/__init__.py index c82748c..c9a2afc 100644 --- a/custom_components/itag/__init__.py +++ b/custom_components/itag/__init__.py @@ -20,7 +20,8 @@ from .coordinator import ITagCoordinator, ITagError _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.EVENT, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.DEVICE_TRACKER, + Platform.EVENT, Platform.SENSOR] # Optional device target on services; if omitted and only one tag is configured, it's used. _TARGET = {vol.Optional("device_id"): cv.string} diff --git a/custom_components/itag/brand/icon.png b/custom_components/itag/brand/icon.png new file mode 100644 index 0000000..bacbefc Binary files /dev/null and b/custom_components/itag/brand/icon.png differ diff --git a/custom_components/itag/brand/icon@2x.png b/custom_components/itag/brand/icon@2x.png new file mode 100644 index 0000000..6d863cd Binary files /dev/null and b/custom_components/itag/brand/icon@2x.png differ diff --git a/custom_components/itag/brand/logo.svg b/custom_components/itag/brand/logo.svg new file mode 100644 index 0000000..05cd0cb --- /dev/null +++ b/custom_components/itag/brand/logo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + Layer 1 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/custom_components/itag/button.py b/custom_components/itag/button.py index 53ea61b..92a2260 100644 --- a/custom_components/itag/button.py +++ b/custom_components/itag/button.py @@ -56,7 +56,7 @@ class ITagRescanButton(ITagEntity, ButtonEntity): """Drop the link briefly so the room can be re-derived from fresh adverts.""" _attr_name = "Rescan room" - _attr_icon = "mdi:map-marker-refresh" + _attr_icon = "mdi:map-marker-down" _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__(self, coordinator: ITagCoordinator) -> None: diff --git a/custom_components/itag/coordinator.py b/custom_components/itag/coordinator.py index 442ca5c..6770883 100644 --- a/custom_components/itag/coordinator.py +++ b/custom_components/itag/coordinator.py @@ -25,6 +25,8 @@ from bleak_retry_connector import close_stale_connections, establish_connection from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import area_registry as ar +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -56,6 +58,62 @@ def proxy_to_room(name: Optional[str]) -> Optional[str]: return " ".join(parts).title() or base +def _mac_offset(mac: Optional[str], offset: int) -> Optional[str]: + """Add ``offset`` to a MAC's last octet, or None if it would leave 00-FF. + + ESP32 derives every MAC from one base by that offset — WiFi STA, +1 AP, +2 BLE, + +3 Ethernet — and never carries, so neither do we. + """ + base = dr.format_mac(mac or "") + if len(base) != 17: # format_mac passes non-MACs through unchanged + return None + try: + octet = int(base[-2:], 16) + offset + except ValueError: + return None + return f"{base[:-2]}{octet:02x}" if 0 <= octet <= 255 else None + + +def scanner_area(hass: HomeAssistant, source: Optional[str]) -> Optional[str]: + """Name of the HA area assigned to the proxy behind a scanner ``source``. + + Three device-registry entries can carry that area, so try them in the order a + user is likely to have set it (the order bermuda settled on): + + 1. the device the *bluetooth* integration creates per scanner (HA 2025.2+), + keyed ``("bluetooth", )``; + 2. the proxy's own device (ESPHome/Shelly), via ``source_device_id`` on the + per-scanner ``bluetooth`` config entry HA registers; + 3. that same device found by MAC arithmetic — ESPHome reports the ESP32's BLE + MAC as the scanner source while its device entry holds the WiFi MAC. + + An entry with no area is skipped rather than accepted, so the area can live on + whichever page the user actually used. + """ + if not source: + return None + devreg = dr.async_get(hass) + candidates = [devreg.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, source.upper())})] + for entry in hass.config_entries.async_entries("bluetooth"): + if (entry.unique_id or "").upper() == source.upper(): + if device_id := entry.data.get("source_device_id"): + candidates.append(devreg.async_get(device_id)) + break + for offset in range(-3, 3): + if (alt := _mac_offset(source, offset)) is not None: + candidates.append(devreg.async_get_device(connections={ + (dr.CONNECTION_BLUETOOTH, alt.upper()), + (dr.CONNECTION_NETWORK_MAC, alt)})) + device = next((d for d in candidates if d is not None and d.area_id), None) + if device is None: + _LOGGER.debug("scanner %s has no area — assign one on its Bluetooth or " + "proxy device page; falling back to name parsing", source) + return None + area = ar.async_get(hass).async_get_area(device.area_id) + return area.name if area else None + + class ITagCoordinator(DataUpdateCoordinator): """Owns the BLE connection to one tag and exposes its operations.""" @@ -74,6 +132,7 @@ def __init__(self, hass: HomeAssistant, address: str, name: str) -> None: self._suspend_until = 0.0 # rescan: don't reconnect before this self._last_room: Optional[str] = None self._last_proxy: Optional[str] = None + self._last_source: Optional[str] = None # MAC of that proxy -> its HA area self._last_rssi: Optional[int] = None self._connected_via: Optional[str] = None # scanner name holding the link self._ringing = False @@ -185,6 +244,7 @@ def _resolve_connection_source(self, ble_device: BLEDevice) -> Optional[str]: try: source = (ble_device.details or {}).get("source") if source: + self._last_source = source scanner = bluetooth.async_scanner_by_source(self.hass, source) if scanner is not None: return scanner.name or source @@ -283,11 +343,13 @@ def _recompute_location(self, data: dict) -> bool: """ prev_room = data.get("room") if self.connected: - room = proxy_to_room(self._connected_via) + room = (scanner_area(self.hass, self._last_source) + or proxy_to_room(self._connected_via)) if room: self._last_room, self._last_proxy = room, self._connected_via data["room"] = self._last_room or "connected" data["nearest_proxy"] = self._connected_via or self._last_proxy + data["nearest_source"] = self._last_source data["nearest_rssi"] = self._last_rssi data["last_seen"] = dt_util.utcnow() return data.get("room") != prev_room @@ -295,12 +357,14 @@ def _recompute_location(self, data: dict) -> bool: now = self.hass.loop.time() proxies: dict[str, int] = {} ages: dict[str, float] = {} + sources: dict[str, str] = {} for sd in bluetooth.async_scanner_devices_by_address(self.hass, self.address, False): adv = sd.advertisement if adv is None or adv.rssi is None: continue name = sd.scanner.name or sd.scanner.source proxies[name] = adv.rssi + sources[name] = sd.scanner.source ts = getattr(sd.scanner, "discovered_device_timestamps", {}).get(self.address) if ts is not None: ages[name] = now - ts @@ -318,20 +382,25 @@ def score(name: str) -> float: if self._last_proxy in fresh and nearest != self._last_proxy: if score(nearest) - score(self._last_proxy) < ROOM_SWITCH_MARGIN: chosen = self._last_proxy - self._last_room = proxy_to_room(chosen) + self._last_source = sources.get(chosen) + self._last_room = (scanner_area(self.hass, self._last_source) + or proxy_to_room(chosen)) self._last_proxy, self._last_rssi = chosen, fresh[chosen] data["room"] = self._last_room or "unknown" data["nearest_proxy"] = chosen + data["nearest_source"] = self._last_source data["nearest_rssi"] = fresh[chosen] data["last_seen"] = dt_util.utcnow() elif proxies: # between adverts — hold the last-known room rather than flapping data["room"] = self._last_room or "unknown" data["nearest_proxy"] = self._last_proxy + data["nearest_source"] = self._last_source data["nearest_rssi"] = self._last_rssi else: data["room"] = "away" data["nearest_proxy"] = None + data["nearest_source"] = None data["nearest_rssi"] = None return data.get("room") != prev_room diff --git a/custom_components/itag/device_tracker.py b/custom_components/itag/device_tracker.py new file mode 100644 index 0000000..15dc6aa --- /dev/null +++ b/custom_components/itag/device_tracker.py @@ -0,0 +1,41 @@ +"""Device tracker: home while any proxy hears/holds the tag, not_home when none do. + +Exists so a tag can be attached to a `person` — carry it and you're home. +The room sensor says *where*; this only answers home/away. +""" +from __future__ import annotations + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import ITagCoordinator +from .entity import ITagEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + coordinator: ITagCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([ITagDeviceTracker(coordinator)]) + + +class ITagDeviceTracker(ITagEntity, BaseTrackerEntity): + """`room == "away"` (no scanner heard it, no link held) is the only not_home.""" + + _attr_name = None # the tracker *is* the device -> device_tracker. + _attr_source_type = SourceType.BLUETOOTH_LE + + def __init__(self, coordinator: ITagCoordinator) -> None: + super().__init__(coordinator, "tracker") + + @property + def state(self) -> str: + # ponytail: away lags by up to one tick + HA's stale-advert window (~2 min + # worst case). Add an explicit away timer only if that's too slow/jumpy. + room = (self.coordinator.data or {}).get("room") + return STATE_NOT_HOME if room in (None, "away") else STATE_HOME diff --git a/custom_components/itag/sensor.py b/custom_components/itag/sensor.py index 6d1d948..bfda997 100644 --- a/custom_components/itag/sensor.py +++ b/custom_components/itag/sensor.py @@ -41,6 +41,7 @@ def extra_state_attributes(self): data = self.coordinator.data or {} return { "nearest_proxy": data.get("nearest_proxy"), + "nearest_source": data.get("nearest_source"), # scanner MAC -> which proxy device "last_seen": data.get("last_seen"), }