Files
2GT-RichandClaude Opus 5 57d650c159 feat(wireless): reconstruct per-client roaming history from AP logs (v0.23.11-beta)
Closes #105.

RouterOS narrates the whole life of an association in its log — association, every
band or AP change, and the disconnect — each line carrying the signal at that
moment. Nothing else in the platform can see any of it: roaming happens between
polls, so a poll only ever observes where a client ended up, never that it moved.

Sessions are now rebuilt from those lines and shown on the client detail page: the
path across radios, signal at each step, duration, and RouterOS's own disconnect
reason. Sessions averaging six or more roams an hour are flagged, since a client
bouncing between radios is usually a roaming-threshold problem rather than a faulty
client.

No new collection and no new storage. It reads the events already gathered, so it
works retroactively over whatever log history exists.

Two things came out of testing against real logs rather than the samples in the
issue, and both were wrong in ways the samples could not have shown:

  Ordering. RouterOS log timestamps are second-granular, so a reassociation emits
  its disconnect and reconnect inside the same logged second, and collection
  assigns sub-second values in whichever order they were read — observed on a live
  AP as both `.849/.849` and `.096/.097` for the same kind of pair. Sorting on the
  raw timestamp attached the disconnect to the session the connect had just opened,
  so every reassociation reconstructed as a 0-second session while the real one
  disappeared. On one IoT client that was 255 phantom sessions out of 422. Events
  are now ordered by whole second and ties broken by meaning: a disconnect closes
  what came before, a connect opens what comes after. The same client now shows its
  real behaviour — 900s, 840s and 930s sessions, reassociating every 15 minutes.

  Signal sanity. Real logs contain `signal strength 91`. dBm is negative, so a
  positive reading is not a measurement; recording it would draw a client with an
  impossibly perfect signal. Treated as unknown.

21 tests, including the full ping-pong trace quoted in the issue — which the
reconstruction correctly identifies as flapping — plus both timestamp-collision
shapes seen in production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:27:14 -07:00

103 lines
4.6 KiB
Markdown

# CAPsMAN
[← Documentation index](README.md)
An access point provisioned by CAPsMAN holds none of its own wireless configuration —
the controller owns it. Reading the AP directly returns blank SSID, security and band,
which makes a perfectly healthy access point look broken.
The platform models the controller relationship instead.
## Device roles
Every device is classified each poll from `/interface/wifi/capsman` and
`/interface/wifi/cap`:
| Role | Meaning |
|---|---|
| `standalone` | Has radios, manages them itself |
| `cap` | Radios are provisioned by a controller |
| `controller` | Runs CAPsMAN; has no radios of its own |
| `controller_cap` | Runs CAPsMAN **and** its own radios under it — a supported MikroTik arrangement |
| `none` | No wireless hardware |
The role is detected, not inferred from `device_type`. That matters because a dedicated
controller is usually classified as a router, and `device_type` is a label a human typed
rather than a fact about the hardware. The Radios tab and the wireless section follow the
detected role.
## How CAPs are matched to devices
The controller enumerates every radio it manages — local and remote — through
`/interface/wifi/radio`, each with a `radio-mac`. Those MACs are matched against every
interface MAC in the fleet.
MAC rather than IP address, deliberately: a MAC is hardware identity and unique
fleet-wide, whereas an address is only unique within a broadcast domain. An installation
with several segments reusing the same addressing would otherwise attribute a radio to
the wrong device entirely.
Two refinements come from real deployments:
- A controller **mirrors each CAP's interfaces locally**, so a CAP's radio MAC genuinely
appears on two devices. A radio the controller reports as `local: false` lives on a CAP
by definition, so the controller is never a valid answer for it.
- A radio MAC often differs from the device's interface MAC in the final octet, so lookup
falls back to a five-octet prefix while keeping the OUI exact.
A CAP that is **not** in the fleet is surfaced as unmanaged rather than hidden — that is
usually the reason an access point appears to be missing.
## What you get
The **CAPsMAN panel** on the Wireless page lists every controller with the access points
it provisions, one row per radio: the AP it lives on (linked), interface name, provisioned
SSID, operating channel, connected clients, and state.
Two details are read from the controller rather than the CAP, because the CAP does not
know them:
- **SSID** — held on the controller's mirror of the interface.
- **Client counts** — a CAP's own registration table is empty when traffic is processed
centrally. Counts come from the registration table with each client attributed to its
radio by following `master-interface`, since clients register on the virtual AP carrying
the SSID rather than on the physical radio.
## Write protection
A provisioned radio is owned by the controller, so a local write to it is either rejected
outright or silently replaced at the next provision — while the API reports success.
Five paths refuse or skip provisioned radios, naming the controller in the error: bulk
SSID deployment, interface create, interface edit, interface delete, and guest network
setup.
## Current limits
**Provisioning, configuration edits and interface enable/disable are deliberately not
exposed.** A CAPsMAN configuration applies to every access point bound to it
simultaneously — the fleet-wide version of the failure [Change Guard](change-guard.md)
exists to prevent. Those operations are held back until they carry the same
prediction-and-revert protection that per-device changes do.
Legacy `/caps-man` — the pre-RouterOS 7 controller — is a separate API tree and is not
covered. The RouterOS 7 `wifi` stack is supported.
## Roaming history
A client's association history is reconstructed from access point logs at
`GET /api/clients/:mac/roaming?range=24h`, and shown on the client detail page.
Roaming happens between polls, so a poll-based view can never see it — the log is
the only record that a client moved, when, and at what signal. Sessions are rebuilt
from RouterOS's own lines, including every band or AP change and the disconnect
reason.
Sessions with six or more roams per hour are flagged. A client bouncing between
radios usually indicates roaming thresholds rather than a faulty client: overlapping
cells at similar signal give it no reason to prefer one.
Nothing new is collected — this reads the events already gathered, so it works
retroactively over whatever log history exists. It does require association logging
to be enabled on the access points.