discover · compare · collections · requirements · contribute · feed · watching · stats · help with your tokens
darek-margas/wifi-association-presence · source
Snapshot 0 · 2026-11-04T12:47:28Z · Python · GPL-3.0
Content fingerprint
dc481cb8e1a07c1853b1d7b53ee89aaf8fee4ae0bc5d30c5b986201374f26792GitHub description at capture
Home Assistant presence detection from Wi-Fi access point association tables: a device is home while it is associated (joined) to one of your APs, not merely seen in ARP or a switch's MAC table. Pluggable AP drivers: D-Link DAP, UniFi (via Home Assistant's UniFi integration) and OpenWrt.
README at capture
<img src="docs/images/icon.svg" alt="" width="96" align="right">
# Wi-Fi Association Presence
**Know who is home, and in which room, from the Wi-Fi you already have.**
- **Who is home.** Each phone, tablet or laptop shows as home or away in Home Assistant,
and stays home while the phone sleeps in someone's pocket.
- **Which room.** Put each access point in a room (an *area*), and every device shows the
room it is connected in: the living room, the studio, the garden.
- **When.** Arrival and departure times, to the minute, for your automations.
- **How good your Wi-Fi is.** Shows which rooms have a weak signal, which devices
keep dropping off, and which ones hang on to a far access point.
[Coverage dashboard](docs/coverage-dashboard.md).
- **Nothing to install on the phones**, and no app or account: it asks your access points
which devices are connected to them.
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=darek-margas&repository=wifi-association-presence&category=integration)
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=wifi_association_presence)
Guides and help: the [wiki](../../wiki): a setup guide for each type of access point, and
[Troubleshooting](../../wiki/Troubleshooting).
## Setup in four steps
1. **Install** it from HACS (button above) and restart Home Assistant.
2. **Add access point**: choose its type, enter its address and login. Home Assistant
checks the login right away.
3. **Put each access point in its room** (open its device → ✏️ → Area).
4. **Add tracked device**: pick your phone from the list of devices the access points
see, and give it a name. No MAC addresses to look up.
That's all. [More detail on setup](#setup).
Optional: paste one template to get a [Wi-Fi coverage dashboard](docs/coverage-dashboard.md)
showing which rooms have a weak signal and which devices keep dropping off.
## Your access points aren't supported yet?
Install it anyway; you can help get them supported without any technical knowledge:
1. On the integration page press **Collect access point report** and enter your
access point's address and login. Home Assistant reads it once and only displays
information; nothing is changed on the access point.
2. **Download diagnostics** (⋮ on the integration page) and attach the file to a
[New access point model](../../issues/new?template=new_access_point.yml) issue.
Read it first: it is redacted automatically, but check nothing private is left.
3. **Wait for an update that supports your model**, then add your access points as above.
Supported today: **D-Link DAP, OpenWrt, UniFi, MikroTik RouterOS (also CAPsMAN) and Cisco
Catalyst 9800** and **standalone TP-Link EAP**, and **TP-Link Omada** as experimental.
[More about reports](#help-add-your-access-point) · [What each type reads](#supported-access-points)
---
[Supported access points](#supported-access-points) ·
[Install](#installation) ·
[Help add yours](#help-add-your-access-point) ·
Early development, Home Assistant 2026.9+
## Why association, not ARP or MAC tables
Most network-based presence trackers look at a router's **ARP table**, a switch's
**MAC address table**, or ping the device. All of these only notice a phone while it is
*sending traffic*, and phones go quiet for minutes at a time to save battery. The result
is the familiar flapping: home, away, home, while the phone sat on the table.
An access point knows something more fundamental: whether the device is **associated**,
i.e. still joined to the Wi-Fi network. A sleeping phone stays associated. It only
disassociates when it really leaves (or Wi-Fi is turned off).
| | ARP table | Switch MAC table | Ping | **AP association** |
|---|---|---|---|---|
| Sees an idle, sleeping phone | ✗ entries age out | ✗ entries age out | ✗ often no reply | **✓ stays associated** |
| Knows *where* in the house | ✗ | ✗ | ✗ | **✓ which AP, so which area** |
| Signal strength | ✗ | ✗ | ✗ | **✓** |
| Wired devices | ✓ | ✓ | ✓ | ✗ Wi-Fi only |
On top of that, a short **grace period** (default 3 minutes) bridges roaming between APs
and brief gaps, so `not_home` means *gone*, not *quiet*.
## Features
### Presence that doesn't flap
- One tracker per device, **across all your access points**: `home` while associated to
any of them, `not_home` once missing for longer than the grace period.
- **Roaming aware**: a device seen on two APs in the same poll is placed on the one with
the stronger signal.
- **Adjustable grace period** (0–3600 s; below 30 s it acts as 30 s, i.e. away after one
missed poll) for phones that drop Wi-Fi in deep sleep.
### Room-level location with areas
- Assign each access point device to a Home Assistant **area** (Living room, Studio,
Garden...). Every tracked device then gets an **Area** sensor showing the area of the
access point it is connected to, with a stable `area_id` attribute.
- Use it in automations: *lights on in the studio when my phone joins the studio AP*,
*notify when the kids' tablets are in the garden*, *which floor is everyone on*.
- It reacts immediately when you move an AP to another area or rename an area.
### Arrival and departure times
Every tracker tells you **when** the device arrived or left, not just whether it is home:
| Tracker state | `arrived_at` | `departed_at` |
|---|---|---|
| `home` | when it arrived | `null` |
| `not_home` | `null` | when it left |
- **Accurate times.** The departure is the moment the device was last seen associated,
not the moment the grace period ran out, so "left at 08:12" means 08:12 (to within one
poll, a minute), even with a 3-minute grace period. Coming back within the grace period continues the same
visit, so roaming and short gaps don't produce a fake departure and arrival.
- **Made for automations.** Exactly one of the two holds a time, matching the state.
Trigger on an attribute going from `null` to a time and you get one clean event per
arrival or departure, with the exact time in it.
- **Survives restarts.** Arrival and departure times are stored, so a restart doesn't
turn "home since 07:40" into "home since the restart".
- **Instant arrivals** (MikroTik, UniFi, OpenWrt and D-Link DAP). Access points
that report changes as they happen make the integration poll at once, so an arrival
shows in seconds instead of up to a minute. Departures still wait for the grace period,
and the poll, not the event, decides who is there. On by default; switch it off in
**Configure** (for MikroTik and OpenWrt it keeps one SSH connection open per access
point or CAPsMAN controller; UniFi keeps the controller's event stream open; D-Link DAPs send SNMP traps to Home Assistant, see the
[wiki](../../wiki/D-Link-DAP#instant-arrivals-snmp-traps)).
- **Fast departure** (optional, off by default). When an access point reports a client
leaving (OpenWrt, D-Link DAP and UniFi do; MikroTik, Cisco and TP-Link don't), a device
missing from it is away after a short time you choose instead of the grace period. Faster departures, at the cost of devices that drop off Wi-Fi briefly
showing away; see the [wiki](../../wiki/Troubleshooting#fast-departure).
- **Light on the database.** The attributes change only on arrival or departure, so the
recorder writes a row per event, not per poll. The tracker's History and Logbook then
list every arrival and departure.
Examples (replace `device_tracker.darek_phone` with your tracker):
```yaml
# Tell me when someone leaves, with the time they actually left.
triggers:
- trigger: state
entity_id: device_tracker.darek_phone
attribute: departed_at
conditions:
- condition: template
value_template: "{{ trigger.to_state.attributes.departed_at is not none }}"
actions:
- action: notify.notify
data:
message: >-
Darek left at
{{ as_timestamp(trigger.to_state.attributes.departed_at) | timestamp_custom('%H:%M') }}
```
```yaml
# Welcome home: lights on when an arrival is recorded after dark.
triggers:
- trigger: state
entity_id: device_tracker.darek_phone
attribute: arrived_at
conditions:
- condition: template
value_template: "{{ trigger.to_state.attributes.arrived_at is not none }}"
- condition: sun
after: sunset
actions:
- action: light.turn_on
target:
area_id: hallway
```
```jinja
{# Dashboard text: "home for 2 hours" / "away for 3 days" #}
{% set t = 'device_tracker.darek_phone' %}
{% if is_state(t, 'home') %}home for {{ state_attr(t, 'arrived_at') | as_datetime(default=none) | relative_time }}
{% else %}away for {{ state_attr(t, 'departed_at') | as_datetime(default=none) | relative_time }}{% endif %}
```
A device that is already home when you add it shows that moment as its arrival until it
next leaves and comes back.
### Per device insight
Each tracked device is a Home Assistant device with:
- **Tracker** with attributes `access_point`, `area`, `ssid` and `band` (kept after the
device leaves, as "last seen at"), plus `arrived_at` / `departed_at`
([see above](#arrival-and-departure-times)).
- **Access point** sensor: the AP's name (the name you gave its device in Home Assistant, else yours, else the name the AP reports, e.g. *Studio*).
- **Signal** sensor: signal quality 0-100 %, comparable across access points and vendors
(APs that report dBm, like OpenWrt and UniFi, are converted; D-Link reports a
percentage), with a matching Wi-Fi strength icon. Its attributes hold the raw value and
unit (`signal`, `signal_unit`).
- **Area** sensor, as above.
### Access point monitoring
An access point read directly (D-Link, OpenWrt) is a Home Assistant device with a link to
its web UI, the details it reports about itself (model, firmware, hardware) and sensors:
- **Clients 2.4 GHz**, **Clients 5 GHz** and **Clients** (total).
- Diagnostics: **Last boot**, **Location**, **CPU** and **Memory**, each only if the AP
reports it (see [what each type reports](#supported-access-points)). CPU and Memory are
disabled by default: they change every poll, so each would be a recorder row per minute.
- Real SSID names, also where the AP itself only shows an index (D-Link's "SSID index 3").
An access point read through another integration (UniFi, TP-Link Omada) gets no device of its own: it uses the
AP's existing device from that controller's integration, which already has these details.
### Wi-Fi coverage
The integration counts these from the polls it already makes. You don't need a floor plan
or an app. The [coverage dashboard](docs/coverage-dashboard.md) explains each sensor and
has a template that builds the whole dashboard from your own access points and devices,
so there are no entity ids to look up.
- **Per access point:** **Average client signal** (0-100 %) and **Weak clients** (how
many are below 50 %, about -75 dBm). These come only for access points with their own
device (not UniFi or Omada).
- **Per tracked device:** **Roams today**, **Short drops today** and **Late roams today**.
A short drop means the device vanished and came back within the grace period, which
presence hides. A late roam means the device switched AP and gained 15 dB or more,
because it had stayed too long on a far AP.
- The counts start again at midnight. They are saved with the tracked devices, so a
restart during the day keeps them.
### Easy and robust
- **Set up entirely in the UI.** Access points and tracked devices are entries of the
integration: add, edit and remove them from its page. Pick devices to track from a list
of everything seen in the last 7 days, so a sleeping car or tablet can be picked too.
- **Fault tolerant.** An unreachable, slow or misbehaving access point only affects itself:
its sensors go unavailable and its clients age out after the grace period, while the
other APs keep updating. Unexpected replies are discarded, not shown as values.
- **Independent trackers.** Trackers are keyed by MAC address only, so you can remove,
replace or change the type of an access point without losing them.
- **Pluggable drivers.** Each kind of AP is a small driver (so far D-Link, OpenWrt, UniFi,
MikroTik, Cisco 9800, TP-Link Omada and standalone TP-Link EAP); new ones can be added without touching the
rest. The
drivers are a separate library,
[wifi-ap-associations](https://pypi.org/project/wifi-ap-associations/), which other
tools can use too.
## Screenshots
The integration page: one hub, with access points and tracked devices as entries. The
access point shows as *Studio*, the name it reports about itself.

An access point device: firmware, hardware, client counts per band, CPU, memory, last boot.

A tracked device: its tracker, the access point it is connected to, the area of that
access point, and the signal.

The [coverage dashboard](docs/coverage-dashboard.md), built by a template: average client
signal and weak clients per access point (plus roams, short drops and late roams per
device, and graphs).

## How presence is decided
Every 60 seconds all access points are read in parallel: one SSH login per directly read
AP, one request per controller for all its APs.
A device seen on two APs in the same poll (while roaming) is attributed to the one with
the stronger signal.
| Situation | Tracker state |
|---|---|
| Associated to any AP now | `home` |
| Not seen for less than the grace period | still `home` |
| Not seen for longer than the grace period | `not_home` |
| No access point configured | `unknown` |
| No access point could be read at all | unavailable (after the grace period) |
The last two rows differ on purpose. When only some APs fail, a device last seen on a
failing one ages out to `not_home`: it may have moved to an AP that still works, which
would show it. When none can be read there is no evidence about anyone, so after the
grace period the trackers become unavailable rather than claiming everyone left. A short
total outage, such as a controller restart, is ridden out on the last sightings.
Each stay is a **visit**: it starts when the device is first seen (`arrived_at`) and ends
when it was last seen before going away (`departed_at`). Coming back within the grace
period continues the same visit, so roaming and short gaps don't reset "home since".
Sightings and visits are kept for 7 days and survive restarts.
**Restarts.** The sightings are written every 10 minutes and again as Home Assistant
stops. After a normal restart, however long, a device that was home at the stop and is
seen again continues its visit: Home Assistant being down is no evidence that anyone
left. After a crash or power cut the last write may be up to 10 minutes old, and a device
could have left after it without being noticed, so a device seen again then starts a new
visit (a fresh `arrived_at`) rather than one that might hide a departure.
Tracked devices are independent of access points. Removing, replacing or changing the
type of an access point doesn't touch them.
## Installation
Requires Home Assistant **2026.9** or newer.
**HACS:** click
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=darek-margas&repository=wifi-association-presence&category=integration)
(HACS offers to add the repository), then **Download**, and restart Home Assistant. By
hand: HACS → ⋮ → **Custom repositories** → add this repository's URL with type
**Integration**, install **Wi-Fi Association Presence**, and restart.
**Manual:** copy `custom_components/wifi_association_presence` to
`/config/custom_components/` and restart Home Assistant.
Either way, Home Assistant installs the access point drivers
([wifi-ap-associations](https://pypi.org/project/wifi-ap-associations/), from PyPI) when
it first loads the integration, so it needs internet access at that restart.
## Setup
1. **Settings → Devices & services → Add integration → Wi-Fi Association Presence**, or
click
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=wifi_association_presence)
(after installing and restarting).
2. On the integration page, **Add access point**: choose the type, then its settings.
The settings are tested by reading the AP once. Leave the name empty to use the name
the AP reports about itself (D-Link's system name, OpenWrt's host name; a UniFi or
Omada AP uses its name on the controller).
3. Open each access point's device and set its **Area** (✏️ → Area). This is what the
trackers' *Area* sensor reports.
4. **Add tracked device**: pick a device from the list or type its MAC address, and give it
a name. The list holds every device seen in the last 7 days, most recent first, labelled
with AP, band, SSID and signal; devices not connected right now (a sleeping car or
tablet) show when they were last seen. This memory survives restarts.
5. Optional: **Configure** on the hub sets the grace period, turns instant arrivals on
or off, sets fast departure, and the port for D-Link SNMP traps.
Access points and tracked devices can be edited from their **⋮** menu (**Reconfigure**).
Leaving an access point's password empty keeps the current one.
## Supported access points
| Type | Models | Method |
|---|---|---|
| D-Link DAP (SSH console) | **Tested:** DAP-2610 (fw v2.06, [report](docs/ap-reports/dlink-dap-2610-v2.06.txt)), DAP-3662. **Likely:** other DAP models with the same CLI Setup guide: [wiki](../../wiki/D-Link-DAP). | SSH: `config wlan 0/1` + `get clientinfo` |
| UniFi Network controller | **Confirmed** on a UniFi Network controller by a user. Any UniFi AP, or console/gateway with built-in Wi-Fi, managed by a UniFi Network application (UniFi OS console, Cloud Key or self-hosted); read through the controller with a local account (since 0.12; the login checked against a self-hosted Network application 10.0). Setup guide: [wiki](../../wiki/UniFi). | The controller's active client list (`stat/sta`) and devices (`stat/device`) over its web API, one login and one read shared by its APs; its event stream for instant arrivals |
| OpenWrt (SSH, ubus) | Any AP running OpenWrt with its standard hostapd (`wpad-*`). **Verified** on OpenWrt 25.12.5 end to end in Home Assistant and on 22.03.7, in VMs with simulated radios; reports from physical routers welcome Setup guide: [wiki](../../wiki/OpenWrt). | SSH: `ubus call hostapd.<radio> get_clients` / `get_status` for every radio, `ubus call system board` / `info` |
| MikroTik RouterOS (SSH) | RouterOS access points and routers with Wi-Fi. **Confirmed** in Home Assistant on a cAP lite with the `wireless` package, RouterOS 7.24.4 ([#12](https://github.com/darek-margas/wifi-association-presence/issues/12)), with signal; system details also on RouterOS 7.20 (a CRS310 switch). For CAPsMAN with one access point per CAP, use the MikroTik CAPsMAN type below. Setup guide: [wiki](../../wiki/MikroTik-RouterOS). | SSH, one exec request per command: `/system identity`, `/system resource`, `/interface wifi` (RouterOS 7) and `/interface wireless` (RouterOS 6, and RouterOS 7 radios still on it) `registration-table print terse` and `print terse`, both read and combined (signal and uptime of `wireless` from `registration-table print stats terse`) |
| MikroTik CAP via CAPsMAN (SSH) | CAPs managed by a CAPsMAN controller on RouterOS 7 (`wifi` package); one access point here per CAP, picked by its identity on the controller. **Confirmed** in Home Assistant on a CAPsMAN router with two cAP ax on RouterOS 7.24.4 ([#11](https://github.com/darek-margas/wifi-association-presence/issues/11)). Setup guide: [wiki](../../wiki/MikroTik-RouterOS). | SSH exec on the controller, one login shared by its CAPs: `/interface wifi capsman remote-cap`, `/interface wifi` and its `registration-table`, each `print terse`; each client put on the CAP its interface (or its master interface) belongs to |
| Cisco Catalyst 9800 controller (SSH or SNMP) | Lightweight Cisco APs (9120, 9130, ...) joined to a Catalyst 9800 controller (IOS-XE); one access point here per Cisco AP, picked by its name on the controller. **Confirmed over SSH** on a C9800-CL (IOS-XE 17.15.5, C9120AXI, [#10](https://github.com/darek-margas/wifi-association-presence/issues/10)) in Home Assistant, with signal, using a restricted parser view, and **over SNMP** (v3) in Home Assistant, with signal on every client; its clients, SSIDs and bands matched SSH. Setup guide: [wiki](../../wiki/Cisco-Catalyst-9800). | SSH: the controller's console, one login shared by its APs: `show ap summary`, `show wlan summary`, `show wireless client summary`, and up to 15 clients' `show wireless client mac-address <mac> detail` per poll, in turns, for signal and connected time. SNMP (v2c or v3, chosen in the settings): the Airespace client table (AP, SSID, state, protocol), client statistics (signal) and AP table (name, location, status, model), plus Cisco's client table (connected time), one read shared by its APs |
| TP-Link Omada controller, **experimental** | Omada access points (EAP) managed by an Omada controller, version 5 or later (software, OC200/OC300, or a router with a built-in controller); read through the controller with a local account (since 0.12; the login checked against a software controller 5.15, not yet with access points). Testers wanted in the [Omada discussion](https://github.com/darek-margas/wifi-association-presence/discussions/4). Setup guide: [wiki](../../wiki/TP-Link-Omada). | The controller's connected clients and devices over its web API (v2), one login and one read shared by its EAPs |
| TP-Link EAP, standalone (SNMP) | TP-Link EAP access points running without an Omada controller. **Confirmed** on an EAP670 (firmware 1.3.13) in Home Assistant; other models welcome in the [Omada discussion](https://github.com/darek-margas/wifi-association-presence/discussions/4). Setup guide: [wiki](../../wiki/TP-Link-EAP). | SNMP (v2c or v3): TP-Link's client table (the clients' MACs) and the system name and description |
### What each type reports
| | D-Link DAP | OpenWrt | UniFi | MikroTik | MikroTik CAP via CAPsMAN | Cisco 9800 | TP-Link Omada | TP-Link EAP, standalone |
|---|---|---|---|---|---|---|---|---|
| Clients, band, SSID name | ✓ | ✓ | ✓ | ✓ | ✓ (per CAP) | ✓ | ✓ | clients only |
| Signal | % | dBm | dBm | dBm | dBm | dBm: over SNMP every poll, over SSH each client refreshed about every 2 minutes | dBm | — |
| AP device and sensors created here | ✓ | ✓ | ✓ | ✓ | ✓ (one per CAP) | ✓ | ✓ | ✓ |
| Name | system name | host name | controller name | identity | CAP identity | AP name | controller name | system name |
| Model | — (enter it in the AP's settings) | ✓ | ✓ | ✓ (board name) | ✓ (board name) | ✓ | ✓ | ✓ |
| Firmware, hardware | ✓ | ✓ | firmware | firmware | firmware | — | ✓ | — |
| Last boot (uptime) | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | — |
| Location | ✓ | — (no such setting) | — | — | — | ✓ (if set on the controller) | — | — |
| CPU % | ✓ | — (only load averages) | ✓ | ✓ | — | — | ✓ | — |
| Memory % | ✓ | ✓ | ✓ | ✓ | — | — | ✓ | — |
| Login | SSH, password | SSH, password | the controller's: a local account | SSH, password | SSH, the controller's | the controller's: SSH (a parser view with five commands, see the [wiki](../../wiki/Cisco-Catalyst-9800); privilege 1 gives presence only), or SNMP v2c / v3 | the controller's: a local account | SNMP v2c / v3 |
Sensors for details a type can't report are not created. No type gives clients' IP
addresses or host names to this integration, so trackers have no `ip` / `host_name`.
### Two ways to read an access point
Each type of access point is a driver, and there are two kinds:
- **Directly from the AP** (D-Link and OpenWrt, over SSH). Works without any controller,
and with only the APs you are allowed to log in to.
- **Through a controller** (UniFi and TP-Link Omada over the controller's web API; Cisco
9800 over SSH or SNMP; MikroTik CAPsMAN over SSH). One login and one request for all
of a controller's APs, but you need an account on the controller.
Both can exist for the same vendor and can be mixed in one installation, because every
access point is its own entry and trackers don't care which driver saw a device. That
matters outside a simple home network: in an office or a shared building you may be
given access to the APs in your area but not to the controller, or your area may be
covered by APs of two different controllers of which you only need a few. Either way,
each AP keeps its own device and area, so room-level presence works the same.
### UniFi setup notes
The full guide is in the [wiki](../../wiki/UniFi).
- A **local account** on the controller (a View Only / Read Only role is enough), not a
UniFi cloud account with two-factor sign-in.
- **Add access point → UniFi Network controller**: the controller's address, port (443
for a UniFi OS console, 8443 for a self-hosted Network application), the account and
the site (`default`), then pick the AP from the controller's list. One entry per
physical AP; they share one login and one read.
- Each poll requests the controller's active client list and devices once, shared by all
its APs; a client is associated to an AP while the controller lists it there. Signal is
in dBm. The controller's event stream makes an arrival poll at once.
- An AP the controller reports as offline (or not adopted) counts as unreadable, and
tracked devices on it fall back to the grace period, as with any failing AP.
- **Added before 0.12? Breaking change:** those read through Home Assistant's UniFi Network integration,
which a custom integration may not do; after the update a repair notice asks to
**Reconfigure** each with the controller's login. Home Assistant's UniFi integration
can stay; the two are independent.
### TP-Link Omada setup notes (experimental)
The full guide is in the [wiki](../../wiki/TP-Link-Omada).
- A **local account** on the controller (Viewer role is enough), controller version 5 or
later.
- **Add access point → TP-Link Omada controller**: the controller's address, port (443
for an OC200/OC300 or a router, 8043 for the software controller), the account and the
site (`Default`), then pick the EAP from the controller's list. One entry per EAP.
- Each poll asks the controller for its connected clients and devices once, shared by
all its EAPs. Signal is in dBm. An EAP the controller doesn't show as connected counts
as unreadable.
- No instant arrivals: the Omada controller has no event feed for them.
- **Added before 0.12? Breaking change**, as for UniFi: a repair notice asks to **Reconfigure** each EAP
with the controller's login.
### OpenWrt setup notes
The full guide is in the [wiki](../../wiki/OpenWrt).
- Works with OpenWrt's standard hostapd (`wpad-basic-*`, `wpad-*`), which exposes every
radio on ubus. Log in as `root` (or a user allowed to run `ubus`) with a password.
- What you get depends on the release, as read from OpenWrt's hostapd source:
| OpenWrt | Clients, band | Signal | SSID name |
|---|---|---|---|
| 21.02, 22.03, 23.05, 24.10 | ✓ | ✓ (dBm) | ✓ |
| 18.06, 19.07 | ✓ | — | — |
| 17.01 | ✓ | — | — |
- Only associated and authorized stations count (a phone still in the WPA handshake
doesn't), as in Home Assistant's own ubus tracker.
- Both interface namings are found automatically (`hostapd.wlan0` up to 22.03,
`hostapd.phy0-ap0` from 23.05).
- Device details: host name, model, firmware, hardware (board name), uptime and memory.
- Verified on OpenWrt 25.12.5 (clients with SSID, band and signal, device details, areas,
departure / arrival in Home Assistant) and 22.03.7 (same driver results with the older
`hostapd.wlan0` naming and Dropbear 2022.82). Tested in VMs with simulated radios
([how](docs/openwrt-vm-test.md)); the radios are the only simulated part.
- Please report how it works on your router, especially older releases (see
[Help add your access point](#help-add-your-access-point); `wifi-ap-collect --profile openwrt`).
### D-Link DAP setup notes
The full guide is in the [wiki](../../wiki/D-Link-DAP).
- Enable SSH under **Maintenance → Administration → Console Settings** (protocol SSH).
- The AP only supports password login. Limit management access with
**Limit Administrator** to your Home Assistant host.
- `get clientinfo` lists the radio selected with `config wlan 0` (2.4 GHz) or
`config wlan 1` (5 GHz); `set band` does not change it. The `config wlan` command is
missing from the AP's own `help` output but documented in D-Link's CLI manuals.
- The AP's MAC address isn't available (`get macaddress` fails on the tested firmware).
- These units only offer old SSH algorithms (`diffie-hellman-group14-sha1`,
`diffie-hellman-group1-sha1`, `ssh-rsa`, CBC ciphers). The driver allows them for D-Link
only; other types use current algorithms.
- Instant arrivals: set the DAP's **SNMP trap server** to Home Assistant's address. The
integration listens on UDP 162 (nothing to do on Home Assistant OS and Supervised; see
the [wiki](../../wiki/D-Link-DAP#port-162-on-your-installation) for Container and Core).
## Limitations
**Access point support**
- **Seven kinds of access point so far:** D-Link DAP, OpenWrt, UniFi (through its
controller), MikroTik RouterOS (also CAPsMAN), Cisco Catalyst 9800 (over SSH or SNMP),
TP-Link Omada (through its controller, experimental) and standalone TP-Link EAP
(SNMP). Support for more depends on owners contributing data (see
[Help add your access point](#help-add-your-access-point) and the
[Roadmap](#roadmap)); the driver interface is designed for it.
- **Each type gives what its AP offers**, no more (see
[What each type reports](#what-each-type-reports)). No type gives clients' IP addresses
or host names, so Home Assistant features that rely on a tracker's IP aren't available.
- Signal quality is an approximation: D-Link reports a percentage, and dBm values are
mapped linearly (-100 dBm = 0 %, -50 dBm = 100 %).
- **Coverage is what each access point hears** from the device it serves, not how the
other APs hear it. So it shows where clients struggle, but it can't draw a map.
**Presence**
- **Wi-Fi only.** Wired devices, and devices on networks served by other equipment, are
not seen.
- **Detection delay:** arriving is detected within one poll (up to 60 s); leaving takes
the grace period plus up to one poll. The poll interval is fixed by design (Home
Assistant integrations don't expose polling intervals); the grace period is adjustable.
- **Private / randomized MAC addresses:** track the address the device uses on *your*
network (phones show it per network). Devices that rotate their address periodically
(e.g. iOS "Rotating" private address) will stop matching; set them to a fixed private
address for your network.
- **Devices that drop Wi-Fi to save power** (some Android phones, laptops in sleep)
disassociate and will show as away unless the grace period covers the gap.
**Security**
- **SSH types (D-Link, OpenWrt) log in with a password and do not verify the AP's host
key** (it changes on a factory reset or reinstall). Keep the APs' management access
restricted to the Home Assistant host, preferably on a trusted network segment. D-Link
also needs old SSH algorithms ([D-Link notes](#d-link-dap-setup-notes)).
- The AP password is stored in Home Assistant's configuration storage, like other
integrations' credentials. UniFi and Omada need none here: they use the UniFi or Omada
integration's login.
- Each poll of an SSH type is a login; the AP may log every one (syslog noise).
**Project state**
- Early development: limited testing (a few installations; OpenWrt so far in a VM), English
only, not yet in the default HACS list (add it as a custom repository).
## Help add your access point
You don't need to write code to get an access point supported. Collect a report from it
and attach it to a
[New access point model](../../issues/new?template=new_access_point.yml) issue.
**In Home Assistant (SSH or SNMP, nothing to install):**
1. Settings → Devices & services → **Wi-Fi Association Presence** → the blue
**Collect access point report** button at the top (next to *Add access point*).
2. Choose how to read it:
- **SSH console**: address, SSH port, username and password, and a command list (or
*Generic*). Turn on *Allow old SSH algorithms* for older models such as D-Link DAP.
- **SNMP v2c**: address and a read-only community.
- **SNMP v3**: address, user, keys and algorithms.
For SNMP, *Host type* picks what is read: *Generic* walks the vendor's whole MIB (up
to 5000 values per table). For a Cisco controller choose *Cisco Catalyst 9800* (only
the client, client signal and AP tables a 9800 needs) or *Cisco wireless controller*
(those plus Cisco's AP and WLAN MIBs): only those tables are read, complete.
Passwords, communities and keys are used for this collection only and not stored.
3. Wait for *Report collected* (SSH: up to a minute or two; SNMP: up to five). You can
close the window meanwhile: the collection continues. Then open the menu (⋮) of the
*Wi-Fi association presence* hub → **Download diagnostics**. The report is in
`access_point_report`; if collecting failed, `last_attempt` says why.
4. Read it (see below), then attach the file to the issue. Besides the report, the file
holds Home Assistant's usual diagnostics details: your Home Assistant version and the
list of custom integrations you have installed.
The report stays in memory until Home Assistant restarts or you collect another one.
Only one report can be collected at a time; closing the window does not release that
slot until the collection finishes. Submitted credentials are discarded when it finishes.
**From a computer:** `wifi-ap-collect`, on any computer on the same network with Python
3.12 or newer (`pip install wifi-ap-associations`). It runs the same commands and walks
with the same redaction, and has more options (see below).
> **Read the report before you submit it, and redact by hand what is still private.**
> The collector removes MAC, IP and email addresses, key-like strings, the credentials
> you typed and settings with secret-looking names, but no automatic redaction recognises
> everything: a Wi-Fi passphrase, names, locations or serial numbers can look like
> ordinary text. Replace anything you don't want public with `<redacted>`; the structure
> is what matters for adding support. Issues are public.
**SSH console**
```bash
pip install wifi-ap-associations
wifi-ap-collect --host <AP IP> --username <user>
```
- It runs built-in display commands and validates extra commands: `show` / `get` /
`display` commands, read-only `ubus` calls and the usual client-list commands
(`iw dev <if> station dump`, `iwinfo <if> assoclist`, `wlanconfig <if> list`,
`wl assoclist`, MikroTik `/... print`) are accepted; shell chaining, redirection,
expansion and anything else are refused
and **redacts** MAC addresses (vendor prefix kept), IP and email addresses, key-like
strings and secret-looking settings. JSON replies are split one key per line so secret
keys are caught too.
- `--legacy-ssh` allows old SSH algorithms if the connection fails.
- `--command "<cmd>"` (repeatable) adds the command your AP uses to list clients.
- `--profile dlink_dap` uses the D-Link command list, `--profile openwrt` the OpenWrt one
(ubus hostapd and system calls), `--profile unifi` the UniFi one
(`info`, `mca-dump`; log in with the device SSH credentials set in the UniFi
controller), `--profile cisco_wlc` the Cisco wireless controller one (Catalyst 9800:
AP, WLAN and client summaries), `--profile mikrotik` the MikroTik RouterOS one (system
details, interfaces, the client table of each RouterOS Wi-Fi package and, on a CAPsMAN
controller, its radios and CAPs; each command run on its own instead of in an
interactive terminal); `--list-profiles` shows all. In
Home Assistant these are the *Command list* choices. MAC addresses are redacted
in any notation (`aa:bb:..`, `aa-bb-..`, Cisco `aabb.ccdd.eeff`, bare `aabbccddeeff`),
as are IPv6 addresses and Cisco serial numbers.
**SNMP**
```bash
pip install wifi-ap-associations
wifi-ap-collect --host <AP IP> --snmp # v2c, asks for the community
wifi-ap-collect --host <AP IP> --snmp --snmp-user <user> # v3 (SHA / AES-128)
wifi-ap-collect --host <AP IP> --snmp --snmp-user <user> --snmp-auth sha256 --snmp-priv aes
wifi-ap-collect --host <AP IP> --snmp --profile c9800 # Cisco Catalyst 9800 tables only
```
There is no standard SNMP table of associated Wi-Fi clients: every vendor keeps it in its
own private MIB, if it offers one at all. The collector finds the vendor's subtree from
the AP's `sysObjectID` (its enterprise number, e.g. 171 for D-Link, 14988 for MikroTik),
walks it together with the standard 802.11 and bridge tables, and lists the tables that
contain MAC addresses, the likely client tables, at the top of the report. MACs are
redacted in values and inside OID indexes (where many vendors put the client's MAC), and
the community or keys you type are removed. Use a read-only community. SNMPv3 takes
`--snmp-auth` (md5, sha, sha224, sha256, sha384, sha512) and `--snmp-priv` (des, 3des,
aes, aes192, aes256, and aes192c / aes256c for Cisco's key extension).
A vendor walk contains the AP's whole configuration, which can include e-mail settings,
password hashes or even a Wi-Fi passphrase. So text and binary values are shown only as
their length (`STRING(9 chars)`), except in the likely client tables; numbers, MACs and
the table layout are kept, which is what a driver needs. `--snmp-full-values` shows all
values (still with the redaction above): use it only if asked, and read the result
carefully. `--snmp-root <OID>` adds a subtree, `--snmp-max` limits each walk (default
20000 values).
With a host type, `--profile` reads only that device's tables instead of the vendor
subtree, each up to 50000 values, so nothing is cut off by the limit: `c9800` (Cisco
Catalyst 9800: client table, client statistics with signal, AP table, Cisco's client
MIB) or `cisco_wlc` (any Cisco wireless controller: those plus Cisco's AP and WLAN MIBs).
`wifi-ap-collect --list-profiles` lists them.
Not every AP lists clients over SNMP: the DAP-2610, for example, only reports client
counts and its MAC filter lists there, which is why its driver uses SSH.
Example: [`docs/ap-reports/dlink-dap-2610-v2.06.txt`](docs/ap-reports/dlink-dap-2610-v2.06.txt)
is the report the D-Link driver was checked against (DAP-2610, firmware v2.06,
`--profile dlink_dap --legacy-ssh`). It shows the command list, the client table for each
radio, and how redaction looks. Reports for supported models are kept in
[`docs/ap-reports/`](docs/ap-reports) as reference data for driver and parser work.
If your AP lists clients only in its web UI, say so in the issue.
## Roadmap
One entry per access point, whichever way it is read (see
[Two ways to read an access point](#two-ways-to-read-an-access-point)): where a vendor
offers both, a direct driver and a controller driver can exist side by side. Every item
below needs an owner of that hardware to send data and test a build.
| Next | How | What's needed |
|---|---|---|
| **UniFi APs without a controller in HA** | SSH to each AP, `mca-dump` (JSON with each radio's station table and dBm signal) | `wifi-ap-collect --profile unifi` report |
| **SNMP drivers** | One SNMP base driver plus a small table map per vendor, from the Cisco 9800 one (MikroTik registration table next; Aruba, Ruckus as reports arrive) | `wifi-ap-collect --snmp` report |
| **OpenWrt over HTTP** | The same ubus calls through `/ubus` (JSON-RPC, as LuCI uses), for routers without SSH access; needs a small read-only rpcd ACL | interest |
| **TP-Link EAP signal and band (standalone)** | The SNMP driver gives presence only; `show wifi status` over SSH may list stations with signal | its output from an EAP |
| **Other controllers** (older Cisco AireOS WLCs, Aruba Instant) | Through Home Assistant's integration where one exists, else the controller's API or CLI, as for the Catalyst 9800 | interest and a test setup |
**Instant arrivals**, more sources: MikroTik's `print follow-only` is in (0.10.0,
confirmed on RouterOS 7.24), UniFi's connect and roam events, OpenWrt's hostapd
events and D-Link's SNMP traps are in 0.11.0; next, RADIUS accounting (a
DAP's syslog turned out to be queued and lossy, so not syslog). An event triggers an immediate poll,
so an arrival shows in seconds instead of up to a minute; departures still wait for the
grace period. RADIUS accounting also identifies the 802.1X user, which would allow
tracking a person even with rotating private MAC addresses. Design and test results:
[docs/design/instant-arrivals.md](docs/design/instant-arrivals.md).
An idea: **an MQTT bridge**, for Node-RED, openHAB, ioBroker, Domoticz or anything else
that speaks MQTT, without Home Assistant. A small service built on the same
[wifi-ap-associations](https://pypi.org/project/wifi-ap-associations/) library polls the
access points and publishes, per access point, its associated clients (MAC, SSID, band,
signal) and its health, and per device whether it is home and on which access point.
Shipped as a container and configured through environment variables: the broker, and
each access point's type and settings, with passwords from Docker secrets or an env file
rather than the compose file. Optionally Home Assistant MQTT discovery, for installations
that keep Home Assistant elsewhere. First step done in 0.11.0b4: the presence rules (grace
period, roams, arrival and departure times, coverage) are now in the library, so the
bridge and the integration decide home and away the same way. Proposed configuration,
topics and container: [docs/design/mqtt-bridge.md](docs/design/mqtt-bridge.md).
Done in 0.6.0: the access point code is now a standalone library on PyPI,
[wifi-ap-associations](https://pypi.org/project/wifi-ap-associations/), tested and
released on its own and usable by other tools.
## Development
The code is in two repos:
| Repo | Holds | Released as |
|---|---|---|
| [wifi-ap-associations](https://github.com/darek-margas/wifi-ap-associations) | The access point drivers: logging in, reading the association table and the AP's own details; and the presence rules (`presence`, `coverage`, `tracker`: sightings, grace period, visits, coverage, storage). No Home Assistant imports. Driver and presence tests, AP fixtures. | PyPI package `wifi-ap-associations` (tag `v<version>` in that repo) |
| this repo | The integration: the coordinator (polling schedule, storage, logging, devices and areas around the library's `PresenceTracker`), trackers and sensors, configuration flow, the Home Assistant data sources (UniFi, TP-Link Omada), `scripts/` | HACS release (version in `manifest.json`) |
The integration pins the library exactly in `manifest.json`
(`"requirements": ["wifi-ap-associations==<version>"]`), and Home Assistant installs that
version. To work on a driver and the integration together, install your local copy of
the library over the pinned one: `pip install -e ../wifi-ap-associations`.
- Test an AP from the command line: `pip install wifi-ap-associations`, then
`wifi-ap-probe --host <ip> --username admin`
(`--type openwrt_ssh --username root` for OpenWrt; `--list-types` shows all)
- Testing the OpenWrt driver without hardware: [docs/openwrt-vm-test.md](docs/openwrt-vm-test.md)
- Tests: `python3 -m pytest tests` (needs `pytest` and `wifi-ap-associations`; the driver
tests are in the library). The presence rules
(merging AP reads, roaming, grace period, visits, retention, storage, coverage) live in
the library since 0.11.0b4 (`wifi_ap_associations.presence`, `.coverage`, `.tracker`)
and are tested there. `tests_ha/` runs the integration inside Home
Assistant (setup, restarts, outages, UniFi and Omada through stand-ins for their
integrations); CI runs
both, ruff, hassfest and the HACS validation on every push.
- The icon's source is [`docs/images/icon.svg`](docs/images/icon.svg); the PNGs in
`custom_components/wifi_association_presence/brand/` (256 and 512 px) are rendered from
it. Home Assistant 2026.3+ uses them in place of the brands repository.
### Releasing
Bump `version` in `manifest.json`, add a `## <version>` section at the top of
`CHANGELOG.md`, and push to `main`. The [release workflow](.github/workflows/release.yml)
runs the tests and hassfest and, if they pass, creates the tag `v<version>` and a GitHub
release with that changelog section as its notes. HACS offers the new tag as an update.
A version with a pre-release suffix (`0.6.0b1`, `0.6.0rc1`) is published as a GitHub
pre-release: HACS offers it only to users who turn on *Show beta versions* for this
integration. Release the final version (`0.6.0`) once the beta is confirmed.
When a release needs a driver change: bump the library's version and push it (its
*publish* workflow releases it to PyPI, see its
[README](https://github.com/darek-margas/wifi-ap-associations#development)), raise the
pinned version in `manifest.json` here, and push this repo too. Both can be pushed at the
same time: CI here installs the requirements from `manifest.json` and waits up to
15 minutes for a just-released library version to appear on PyPI
(`.github/scripts/install-requirements.sh`), so the integration is never released
against a library version that isn't there.
After such a release, wait about 10 minutes before updating a Home Assistant that already
had the integration. Its installer keeps PyPI's version list cached for up to 10 minutes;
right after a library release it then logs `Unable to install package
wifi-ap-associations==<version>` and keeps the old library. Another restart installs it.
### Adding a driver
Drivers are written in the library
([wifi-ap-associations](https://github.com/darek-margas/wifi-ap-associations)), released
there, and picked up here by raising the version in `requirements` in `manifest.json`.
The library wiki's [Writing a driver](https://github.com/darek-margas/wifi-ap-associations/wiki/Writing-a-driver)
covers the driver interface, controller drivers, testing against a report and releasing.
What a new driver needs on this side:
- labels for new setting names in `strings.json` and `translations/en.json` (data and
data_description of the `details` and `reconfigure` steps, and `selector` options for
settings with choices); `tests/test_strings.py` fails if one is missing;
- nothing else for a controller driver that lists its access points
(`LISTS_ACCESS_POINTS`): the form logs in with the controller settings and offers the
list for the access point setting. A custom integration may not borrow another
integration's logged-in session, so a driver logs in itself;
- a beta, pushed only once the library version it pins is on PyPI.
## License
GPL-3.0