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
6 changes: 5 additions & 1 deletion custom_components/itag/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ with `active: true` connections. No YAML required.
|---|---|
| `event.<tag>_button` | The physical button (`ffe0/ffe1` notify) — also fired on the bus as `itag_button` with `address`, `name`, `room` |
| `button.<tag>_find` / `button.<tag>_stop_ringing` | Beep / silence (Immediate Alert `1802/2a06`) |
| `sensor.<tag>_room` | Room = the proxy holding the GATT link (or the freshest/strongest scanner while disconnected) |
| `device_tracker.<tag>` | `home` while any proxy hears/holds the tag, `not_home` otherwise — add it to a **person** to track home/away |
| `sensor.<tag>_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.<tag>_signal` | Last advert RSSI (diagnostic) |
| `sensor.<tag>_battery` | GATT `180F` battery — only some iTAG variants expose it; stays unavailable otherwise |
| `binary_sensor.<tag>_connected` / `_ringing` | Link + beep state |
Expand All @@ -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.
3 changes: 2 additions & 1 deletion custom_components/itag/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Binary file added custom_components/itag/brand/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added custom_components/itag/brand/icon@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions custom_components/itag/brand/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion custom_components/itag/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 71 additions & 2 deletions custom_components/itag/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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", <scanner MAC>)``;
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."""

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -283,24 +343,28 @@ 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

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
Expand All @@ -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

Expand Down
41 changes: 41 additions & 0 deletions custom_components/itag/device_tracker.py
Original file line number Diff line number Diff line change
@@ -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.<tag>
_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
1 change: 1 addition & 0 deletions custom_components/itag/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}

Expand Down