Skip to content
GitHub

UniFi Network

A plant’s network is equipment. When a PLC goes quiet, the first question is “is it the PLC, or the switch port it’s plugged into”, and until now the answer lived in the UniFi app on somebody’s phone. So HotLoop reads the UniFi console itself: every switch, every port, PoE draw, the WAN links and the site’s health, as ordinary tags. That means alarms, trends, entities, automations, OPC UA and MCP all work on them without knowing UniFi exists.

This is the read-only driver, the OT watch on top of it, the screens and the events stream (U1 to U4 on the roadmap). It never writes to a console. Not a PoE mode, not a port, not a setting. The driver’s Write refuses, and the account it uses can be View Only.

One HotLoop device is one site on one console. The Cloud Gateway and the Express behind it are two consoles, so two devices, with two logins, because each console has its own local accounts.

  1. Make a local account on the console. Settings, Admins & Users, Add Admin, tick Restrict to Local Access Only, role View Only. A Ubiquiti cloud account can’t log in from anything but the app: it needs MFA, and the console answers a script with 403. We name ours hotloop-ro.
  2. Add the device. Protocol unifi, address the console’s URL: https://host for a console running UniFi OS (Cloud Gateway, Dream Machine, UniFi OS Server), https://host:8443 for the self-hosted Network Application. Poll interval 15 s is about right; the driver never reads a console more than once every 10 s whatever it’s set to.
  3. Save without a fingerprint. The first connect fails, on purpose, with the fingerprint of the certificate the console presented. Check it’s your console (the console shows its certificate, or openssl s_client -connect host:443 piped through openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | sha256sum gives the same thing), paste it into certFingerprint, save.
  4. The tags create themselves on the next poll. See below.

A UniFi device can only be saved when HotLoop has a key to encrypt the password with (HOTLOOP_SECRET_KEY_FILE, which the chart sets up on its own). See BACKUP.md.

Option Default What it does
site default The site’s short name, the one in the console’s URLs. default is the first site on every console
username, password required The local account. The password is write-only through the API and encrypted in the database
certFingerprint none The SHA-256 of the console certificate’s public key, as hex. Colons, spaces and upper case are fine
caFile none Trust a CA instead of a pin, for consoles with real certificates
insecureSkipVerify false Trust any certificate. For a bench, never a plant
clientTimeoutSec 90 How long after the console last saw a client it still counts as on the network. Floor 30. See clients

Consoles ship self-signed certificates, and the lazy answer is to skip verification. That’s fine for reading a meter. It is not fine for a login that can see the whole plant network, and from the write PRs on, cycle its ports. A pinned console that presents a different certificate is refused during the TLS handshake, before the username or password is sent, and the error names both fingerprints. The pin is on the public key, so a console that re-issues its certificate on the same key keeps working.

There is no silent “trust on first use”. The one moment trusting a certificate means anything is when a person is looking at it, so that’s when it happens.

Logins: once, and not again unless it has to

Section titled “Logins: once, and not again unless it has to”

Logging in on every check got our own Cloud Gateway to answer 429 and lock the account out. Twice. So:

  • One session per console per process. Every poll, and every site on the console, rides the same login. Two devices for two sites on one console with the same account are one session.
  • It logs in again only when the console says the session is gone (401), one caller at a time, and never more than once a minute, whatever happens. A session that dies twice inside a minute makes the device read bad until the minute is up, and that’s the trade we want.
  • A refused login is not retried. A wrong password, a cloud account, or a 429 lockout waits at least five minutes, longer if the console’s Retry-After says so, and the device’s error says which one it was. Switching the device off and on doesn’t buy another try: the process remembers.
  • It logs out when the last device using a console stops, so restarts don’t pile sessions up on the console.
  • An --mcp-stdio helper never logs in. It runs beside the Gateway, which already holds the session, and a second login from the same box is exactly what earns the 429. Its UniFi devices stay down there while they run in the Gateway.

Every tag is made by the driver, read-only, named from MAC addresses and port numbers, never display names, so renaming a switch in the UniFi app breaks nothing. <mac> is the device’s MAC in lower case without separators, <nn> the port number in two digits.

Tag Type What it is
d.<mac>.state text connected, disconnected, pending, upgrading, provisioning, heartbeat_missed, adopting, …
d.<mac>.online bool, connectivity Connected to the console
d.<mac>.uptime_s int, s Uptime
d.<mac>.cpu_pct, .mem_pct float, % CPU and memory
d.<mac>.temp_c float, °C Temperature, on devices that have a sensor
d.<mac>.clients int Clients connected through it
d.<mac>.fw text Firmware version
d.<mac>.wan<N>.up bool, connectivity WAN N is alive by the gateway’s own health checks (last_wan_interfaces, then last_wan_status). Not the WAN block’s up, which means configured. A console that gives neither reads bad, never up
d.<mac>.wan<N>.latency_ms float, ms Its latency, good only while the WAN is alive
d.<mac>.wan<N>.availability_pct float, % Availability by the gateway’s own monitors over its window (a day), when it reports one
d.<mac>.wan.active text Which WAN carries traffic: the uplink’s WAN if it’s alive, otherwise the first live one, none if none is
d.<mac>.wan.failover bool, problem Traffic is on anything but the first WAN: the plant is on the backup
p.<mac>.<nn>.up bool, connectivity Port link
p.<mac>.<nn>.speed_mbps int, Mbit/s Link speed; 0, good, on a port with no link
p.<mac>.<nn>.rx_bps, .tx_bps float, bit/s Traffic
p.<mac>.<nn>.errors int Receive plus transmit errors
p.<mac>.<nn>.poe_w float, W, power PoE draw, on ports that can supply PoE
p.<mac>.<nn>.poe_mode text auto, off, pasv24, passthrough
site.<subsystem>.status text The site’s health: wan, www, lan, wlan, vpn
site.www.latency_ms float, ms Internet latency, when reported
site.network_version text The Network version the console runs
site.events_live bool, connectivity The console’s events websocket is connected. 0 means HotLoop is polling only, which costs speed and nothing else
site.last_event text The newest event the console reported on the site, in its own words
d.<mac>.last_event text The newest event about that device. Empty until it has had one

The device classes carry through to the entities, so a port’s link comes out as a connectivity binary sensor and its PoE draw as a power sensor without anybody setting them.

Tags add themselves, and never go away on their own. A switch adopted next week shows up as tags on the next poll, on the same session, without restarting anything. A switch that’s unplugged reads online 0 and its ports go bad quality, so an alarm on it says so instead of vanishing with it. Deleting its tags is a person’s decision. A tag somebody switched off stays off: the driver doesn’t get a vote.

Only adopted devices get tags. A switch somebody plugged in and nobody adopted isn’t part of the plant’s network yet. A point the console doesn’t report reads bad, never a made-up zero: the emulated switches in the lab report no PoE draw, and their poe_w tags read bad.

The Edge Relay can poll a console too. It reads the tags its device file declares and doesn’t add any, because it has nowhere to keep them.

Clients, OT networks, and where a PLC is plugged in

Section titled “Clients, OT networks, and where a PLC is plugged in”

Two jobs, both read-only.

Where is it plugged in. A PLC goes quiet and the first question is whether it’s the PLC or the switch port. Link the HotLoop device to its client on the network once, and its drawer says “on USW Lite 8 PoE port 5, link down” before anybody opens the UniFi app. The link also gives the client tags on the console’s device, so the port’s state trends and alarms next to the Modbus values that stopped.

What’s on the SCADA network. Mark a network as OT, accept what belongs on it, and anything else that shows up on it raises an alarm naming it. Somebody plugs a laptop into the SCADA switch at 2 a.m., you know at 2 a.m.

The console’s client list lags. The house notes caught it showing addresses machines had left hours earlier, and the lab controller kept a client listed for over a minute after its switch reported the cable pulled, with the last_seen it left with. So:

  • A client is its MAC. Linking proposes a client by IP, because that’s what you know about your PLC, but a person confirms it once and from then on the MAC is the key. The IP tag says it’s the console’s opinion.
  • Online isn’t “in the list”. A client is online when the console saw it within clientTimeoutSec (90 s) and, for a wired one, its switch port has a link. The port is the fast half: pull the cable and the port reads down on the next read, while the list is still catching up.

Open a device’s drawer. If a UniFi console HotLoop reads lists a client at the device’s IP, the Network section proposes it; an admin clicks Link. No IP to go on (a device addressed by name), link it by MAC. Or through the API, PUT /api/devices/{id}/network, see API.md.

A linked device’s client gets tags and an alarm, “PLC-07 off the network”, priority 1, with a 120 s on-delay so a PLC rebooting doesn’t page anybody.

Mark a network OT (PUT /api/unifi/{id}/networks/{network} with {"ot": true}), then accept what’s on it: everything as it is now in one call (POST .../accept-all), or one device at a time. Marking it makes the alarm “Unknown device on SCADA”, priority 1, on n.<network>.unknown. The newest stranger is named in n.<network>.unknown_last, with its MAC, what it calls itself, its IP and the switch port or SSID it came in on.

A known device can be watched ("watch": true): it gets the same tags and the same off-the-network alarm as a linked one. A PLC is watched. A maintenance laptop that’s allowed on but comes and goes is known and not watched, or it pages somebody every time it leaves.

The alarms are ordinary alarm definitions. Shelve them, acknowledge them, send them to Discord, edit their delay. Take the watch, the link or the OT mark away and HotLoop takes away the alarm it made for it, so a tag nobody reads any more doesn’t hold an alarm open. One an admin wrote by hand is never touched.

The record is HotLoop’s, in its own database (unifi_networks, unifi_known, unifi_links, all in backups), never the console’s. A change reaches the running driver on its next read, on the session it already has: it doesn’t restart the device, because a restart logs out and the once-a-minute login budget would then keep it out for a minute.

Tag Type What it is
n.<network>.clients int Clients on the network now, online by the test above. Every LAN network gets one
n.<network>.unknown int On an OT network: online clients that aren’t on its known list
n.<network>.unknown_last text The newest of them, in words. Empty when there are none
c.<mac>.online bool, connectivity Watched or linked client on the network
c.<mac>.ip text Its IP, as the console last saw it
c.<mac>.sw_mac, .sw_port text, int The switch and port a wired client is on. Kept after the cable comes out: where it was is the point
c.<mac>.port_up bool, connectivity That port’s link. Bad when the switch itself has dropped off the console, because then nobody knows
c.<mac>.last_seen_s int, s Seconds since the console last saw it

<network> is the console’s ID for the network, so renaming SCADA in the app breaks nothing. A network marked OT that the console has since deleted reads bad with that reason, never a quiet zero. A client the console has never seen reads online 0 and the rest bad, saying so.

Only watched and linked clients get c.* tags. A guest network churning 300 phones doesn’t make 300 tag sets.

GET /api/unifi/{id}/networks, /clients and /known, the drawer, and the MCP tool find_network_client all read the driver’s last read. None of them makes a request of its own, so a screen left open, or an agent asking in a loop, costs the console nothing and can’t be what earns the 429. The flip side: an --mcp-stdio helper, which never logs in, says there’s no console read in its process.

The UniFi tab has six views and a console picker. Everything on it is the driver’s last read of the console, so leaving it open costs the console nothing and it can’t be what earns a 429. It asks HotLoop again every few seconds, and only repaints when something changed, so a map somebody scrolled to the corner stays where they put it.

  • Overview. One card per console. At the top of a gateway’s is the WAN strip: a segment per WAN, green when the gateway’s own health check says it’s alive, red when it isn’t, the one carrying traffic outlined. When the site is running on a backup the whole strip goes amber and says which one and since when. That “since” is when HotLoop first read it that way, not when the gateway switched, and a HotLoop that started while the site was already on the 5G says so from its own start. I won’t print a time I don’t have. Below it: devices online, clients wired and wireless, clients per network, the devices the console has lost, and the latest events. A WAN the console says nothing about is gray and says “no liveness reported”, never green. Every WAN that reports a latency has a trace beside the number, the same line the entity tiles draw, from HotLoop’s own buffer of that tag. An admin gets Create the usual alarms at the bottom of the card, see the starter alarms.
  • Map. Left to right from the gateway. It is laid out the same way every time (a tidy tree, no physics, same read in and the same pixels out), so it doesn’t jiggle on a wall board. The cables are the console’s own downlink_table: a switch is plugged into another only because the console says so. A device nothing lists as plugged into anything stands on its own and says “no uplink listed”. I’m not going to hang it off the gateway on a guess and then be wrong about where the PLC’s network goes. Wired clients sit on the switch and port the console learned them on, wireless ones on their AP and SSID, counted in a chip on the card. Click a device and everything that isn’t downstream of it dims, and the side panel lists what is downstream, its clients, and the lines on the Plant tree it feeds: the HotLoop devices linked to a client below it, each with the equipment path it sits at. The map zooms and moves: pinch with two fingers, drag with one, ctrl-scroll or a trackpad’s pinch about the pointer, or the plus, minus and Fit buttons, from the whole map up to eight times. It is plain SVG, so it stays sharp at any scale, and where you put it stays put while the screen refreshes. A drag that ends on a device does not pick it. Plant overlay puts the HotLoop devices linked to a client in a dashed box per line of the Plant tree, to the right of the network and labelled with the line’s path, each with a cable to the switch port it is plugged into, what the network says about it (up, down or not seen) and what HotLoop says about it. A device linked but on no line is in a box called “Not on the Plant tree”. It stays on once you turn it on. Two consoles are one picture: a box of another console HotLoop reads that this console lists as a wired client is drawn here with its own subtree, dashed, marked “separate console”. The map exports as an SVG for the site binder.
  • Devices. A table, and click one for its faceplate: the ports drawn like the front panel, one row for a small switch, two for 24 and 48 ports with the odd ones above the even. Green for a link, gray for none, red for a port that was up and went down (HotLoop remembers it was up since it started watching), dashed for a disabled one, the PoE bolt and watts, an arrow on the uplink. Click a port for who is on it, what is plugged into it, PoE mode and traffic, with a trace of what it has been receiving and sending. A device the console has lost shows no numbers and no port links, because what it said last isn’t current.
  • Clients. Searchable by name, MAC or IP, with where it is, when the console last saw it, known or unknown on an OT network, and the HotLoop device linked to it. Admins accept an unknown one from the row.
  • Networks. The OT switch, per network, and “accept all as they are”. That changes HotLoop’s record, not the console.
  • WiFi. The SSIDs, whether each is broadcasting, which network it’s on and how many clients are on it. If the console won’t show the account its wireless networks, the screen says so instead of claiming there are none.

The routes behind it are GET /api/unifi/consoles, .../overview, .../topology, .../devices, .../wifi and .../events, documented in API.md.

The alarms a plant wants on its network before anybody has had to think about it, made from what the console has now. Create the usual alarms on the Overview (or POST /api/unifi/{id}/starter-alarms, see API.md) makes:

Alarm On Priority On-delay
a device offline d.<mac>.online, for every adopted switch, AP and gateway 2 60 s, so a firmware reboot pages nobody
an uplink down p.<mac>.<nn>.up on the port each device is plugged into its upstream by 1 10 s
a gateway on a backup WAN d.<mac>.wan.failover, for a gateway with two or more WANs 2 30 s
a PoE port over a limit p.<mac>.<nn>.poe_w, only when you give a limit in watts 3 30 s

They are ordinary alarm definitions: shelve them, acknowledge them, send them to Discord, edit the delay or the limit, delete one. Pressing it again after adopting more gear adds only the new gear, and a tag that already has an alarm on it, made by hand or by an earlier press, is left alone. The PoE alarm takes a limit you give instead of one HotLoop picks, because no console I read says what a port’s budget is, and a number I guessed would be a number someone trusted. “Unknown device on an OT network” and “a watched client off the network” are not in the button: they are made when a network is marked OT and when a device is watched or linked, with the record that gives them a meaning.

The console’s events websocket (/proxy/network/wss/s/<site>/events on UniFi OS, /wss/s/<site>/events on the self-hosted application), on the same session as everything else. It never logs in on its own: a refused handshake with 401 goes through the same login budget as a read, and a refused login is waited out, not retried.

Polling is still the truth. An event never writes a tag. What it does:

  1. It lands in the logbook the moment it arrives, with the controller’s own sentence (“Switch[…] was disconnected”) and its key. A switch dropping is on the timeline when it happens, not when the next poll notices. It is filed under the switch, access point or gateway it is about, not under the console’s own device, because the console is the box the controller runs on and not the thing that dropped. An event that names only a client (the console says who joined, not where) is filed under the switch or AP the last read has that client on, and if the client is new it waits for the read the event has just asked for, up to thirty seconds, then goes under the console if that read never has it. The logbook’s Where filter finds an event from the plant: a HotLoop device linked to its client, the switch the console says that client is on, and every switch between there and the gateway on the console’s own downlink table. A dropped switch is on the timeline of every line it feeds and on none that it doesn’t. Asking for the console itself still works as it always did: the system log by subject equal to the console’s id, and the logbook with the console among the devices in scope, return all of that console’s events, the per-switch ones included.
  2. It goes in the log the Overview shows, and into site.last_event and d.<mac>.last_event, so an automation can trigger on one.
  3. It asks for the next read now. The read floor is ten seconds; an event shortens it to five, so the tags and entities catch up within the next poll instead of up to ten seconds later. A busy WiFi network sends an event for every phone that walks by, and the console isn’t read for each of them.

The controller flags the events that mean something went wrong (is_negative: a switch losing contact is one, a client disconnecting is not), and those are warnings in the logbook. Everything else is information, including keys HotLoop has never seen.

A dead websocket costs speed, never correctness. It reconnects with a backoff of two seconds up to a minute. It pings, and a stream that answers nothing for 90 seconds is dead whatever the network says, so it is closed and reopened. While it is down, site.events_live reads 0, the Overview says “polling only” with the reason, and polling carries on exactly as before.

Against a real UniFi Network Application (10.6) in the Podman lab and in CI, with an emulated USG, two USW-Lite-8-PoE and a Flex Mini adopted into it over the real inform protocol (unifi-emu, MIT), logged in as a View Only account: the login and the one session, a killed session coming back with exactly one new login, the once-a-minute limit, a wrong password refused and not retried, the certificate pin (unpinned, mismatched, right), two sites on one login, the tags creating themselves, device classes on the entities, a switch adopted while HotLoop runs showing up without a restart, and a switch unplugged reading offline with its ports bad.

Against a real UniFi OS Server (5.1, Network 10.6) in the Podman lab: the UniFi OS login, the TOKEN cookie and CSRF token, the Network API under /proxy/network, a logout that really ends the session, and a wrong password answered with 403. Not in CI: it needs systemd, host cgroups and a 2 GB image.

Clients and the OT watch, against the lab controller, with a switch that has things plugged into it. unifi-emu’s switches report every port up and nothing learned on any of them, so the controller never sees a wired client behind one. unifitest.WiredSwitch runs the device side of inform on unifi-emu’s own protocol code and adds what a real switch reports and the emulator leaves out: each port’s mac_table and its link. The controller is real and does everything it does with that: it makes the wired clients in stat/sta and rest/user with their switch and port, puts a VLAN-tagged one on the matching network, and keeps listing a client after its port goes down. The “devices” on the ports are MAC addresses in a switch’s report, not machines on a wire, which is the same bargain as the rest of the lab fleet. Proven there, as the View Only account: the client tags, a pulled cable reading port_up 0 and online 0 on the next read while the console still lists the client, an OT network counting and naming a laptop that isn’t on its list and clearing when it’s accepted, a network the console no longer has reading bad, the unknown-device alarm raising and returning to normal through the real alarm engine, the off-the-network alarm raising when the cable comes out, linking by IP proposal in the device drawer in a real browser, the API, MCP’s find_network_client, and the record surviving a backup and restore.

Against a stand-in only: a 429 lockout with Retry-After. No lab console locks an account out.

The screens and events, against the lab controller (UniFi Network Application 10.6, emulated switches, logged in as View Only), 2026-10-18:

  • The map’s cables are the lab controller’s own downlink_table: a Flex Mini plugged into a Lite-8’s port 3 comes back as {"mac":...,"port_idx":3,"speed":1000} on the Lite-8, and the map draws that edge, one column to the right, with the mini’s uplink port 1 on the far end. Wired clients land on the port they were learned on. A pulled cable reads as a dropped port on the faceplate.
  • A client plugged in: EVT_LU_Connected reached the logbook sink six seconds later, named the client, and the next read (read floor set to an hour, so only the event could have asked for it) had the client on the snapshot.
  • A switch that stopped informing: EVT_SW_Lost_Contact, flagged is_negative by the controller, arrived 1 minute 34 seconds after the switch went quiet, and the switch’s online tag read 0 on the very next poll. The controller takes that long to give up on a switch; HotLoop adds nothing to it.
  • A websocket dropped from the console’s side reconnected and went on, a connection that stayed open and answered nothing was given up on and replaced, and neither cost a login or a poll. Those two were a stand-in console, because no controller can be told to do either. The stand-in is also what shows an amber WAN strip: the lab’s emulated USG has no WANs, so its answers are the house Cloud Gateway’s real ones, with Charter dead and the 5G carrying the site.
  • The events websocket opens as the View Only account on the Network Application, and on the UniFi OS Server lab (5.1) through /proxy/network with the TOKEN cookie and CSRF token, and on both it answers HotLoop’s pings: a quiet site held open for three times its silence limit was never declared dead. A session the console had dropped was a 401 at the handshake, and one login later the stream was open, with exactly one login spent.
  • The UniFi tab in a real browser: the overview with the amber strip, the map with the Lite-8’s feeds listed by Plant tree path and the other switch dimmed, the faceplate with a pulled cable turning red, clients, networks (an admin flips OT and it lands in HotLoop’s record; an operator is offered no switch), WiFi, in both themes at laptop, panel and phone widths with nothing wider than the screen.

The logbook, the starter alarms, the traces, the zoom and the overlay, against the lab controller and, where it can’t do it, the stand-in (2026-10-19):

  • A laptop plugged into a switch reaches the logbook filed under that switch, and the Where filter finds it on the line the switch feeds through a PLC’s confirmed link, and not on a line it doesn’t feed. The console says only who joined, so the switch comes from the next read, and the test proves that wait. The first version of this never showed a UniFi event in the logbook at all: they went into the system log as device events and the logbook read only config and write lines out of it. U4’s test checked the sink, not the logbook.
  • The starter set made from the lab site: an offline alarm for each switch, the Lite-8’s uplink alarm, four PoE alarms at the limit given, and nothing for the gateway’s WANs because the lab’s emulated gateway has none. Pressed twice, the second adds nothing. The uplink alarm raised when the Lite-8’s uplink cable came out, and a switch that stopped informing raised its offline alarm when the controller gave up on it. The failover alarm raised on the stand-in gateway with the house Cloud Gateway’s answers, because the lab can’t be on a 5G.
  • In a real browser: a latency trace on a stand-in gateway’s WAN1 that follows the reading, a port’s in and out traces, the pinch (two emulated fingers) and drag, the limit at eight times, the zoom held across a repaint, the overlay with PLC-07 in Line 2’s box and its network reading going down with the cable, and the export being the whole map. Touch is emulated through the browser’s own input pipeline, not a screen.

Not verified: a PoE alarm firing (the lab’s switches report no PoE draw, so there is nothing to cross a limit), a WAN throughput trace (no console field for it has been read from a real gateway, so there is no throughput trace on the WAN strip, only latency), pinch on a real touchscreen, the screens and the stream on a real Cloud Gateway (nothing here was pointed at the house gear), a real console’s downlink_table and AP events (ap, gw, port and ssid on an event are the same pattern as the sw and user the lab sends, and have not been seen), two real consoles stitched (the lab stitches two sites of one controller), and a real WLAN (the lab site has none, so the SSID list is held to the fields the notes and the reference client name).

Read-only, as a View Only hotloop-ro account, with TestUniFiLive, against the house Cloud Gateway Ultra (UniFi OS, Network 10.6.106, two USW-Lite-8-PoE and a U5G Backup behind it) and the Express 7 (UniFi OS, Network 10.6.106). Each run was one login for six reads, with the certificate pinned. Nothing was written.

  • One login per console, every time. 159 points on the CGU’s site, 60 on the Express’s.
  • WAN1 alive on both (Charter on the CGU at about 20 ms, availability 99.86% over the day), active WAN wan1, failover off. All correct.
  • The dead 5G backup, and how HotLoop’s first read got fooled by it. The U5G Backup has been disconnected since 2026-09-26 23:54 EDT: no power, no link on its switch port. HotLoop first read wan3.up as up, because the CGU’s wan3 block still said up: true with a stale 80% 5G signal. The CGU’s health checks (last_wan_interfaces, last_wan_status) said dead. The driver now reads those, so a dead backup reads down. That’s the exact failure a plant has to see: failover quietly gone, two days and counting, with the gateway’s own summary still looking healthy. TestUniFiADeadBackupWANReadsDown holds the driver to the CGU’s exact fields.
  • PoE read correctly. Every PoE draw read 0 W, and that’s true: the one port allowed PoE (Lite-8 port 1, mode auto, where the U5G lives) has no link, and every other capable port is off, as the house PoE policy wants.
  • Ports read correctly. An empty port reported speed 0, which the driver first called missing data on all 11 down ports; a down port now reads 0 Mbit/s, good.
  • A disconnected device reads disconnected, from the controller’s own state, and its uptime and client count read bad as stale, with that reason.

Not verified yet, and not claimed:

  • Real PoE draw above zero, and a power-cycle. Nothing in the house is drawing PoE right now. That waits for the PoE test device.
  • Temperature: no device on either console, and no emulated one, reports a sensor.
  • A 24 hour soak against the CGU.

Read-only, as the View Only hotloop-ro, with TestUniFiLive and HOTLOOP_TEST_UNIFI_LIVE_DUMP_CLIENTS: one login per console, thirteen reads ten seconds apart on it, against the Cloud Gateway Ultra and the Express 7 (both UniFi OS, Network 10.6.106). Watching clients and marking a network OT were HotLoop’s record, in memory for the run. Nothing was written to either console.

  • View Only reads everything U2 needs on UniFi OS: stat/sta, rest/networkconf, and stat/user/<mac>, which answers “never seen” for a made-up MAC the same way the lab controller does. One login per console, both runs.
  • Wired clients land on the right switch and port, with their IPs. Seven on the CGU’s Trusted network, every one where the rack diagram says it is: node-001 and node-002 on Lite-8 #2 ports 3 and 4, the rest on Lite-8 #1, and the Express 7’s own WAN listed as a client on the CGU’s port 1. On the Express, the workstation and crane-cp-01 on the Flex Mini’s ports 2 and 3. Watched, they read online 1, port_up 1, the right sw_mac and sw_port. The UniFi OS fields are the Network Application’s: sw_mac, sw_port, ip, network_id.
  • Wireless clients carry their SSID. Twelve on the Express, on FBI-Surveillance-Van-2 and TheCluster, each on its network, no switch tags.
  • The 90 s client timeout is right, with room. The oldest last_seen any client had at any of the 26 reads was 22 s on the CGU (wired) and 28 s on the Express (wifi). A connected client never came close to 90, so a watched PLC won’t flap, and a real disappearance still shows inside a minute and a half. Faster than that is the port: its link goes on the next read.
  • The OT watch names a stranger properly. Trusted marked OT for the run with nothing on its list: unknown 7, and unknown_last read 88:a2:9e:27: bd:5a "node-002" at 100.64.110.22 on USW-Lite-8-PoE #2 port 4 joined 2026-09-29 13:30:27 UTC. SCADA marked OT on the Express: 0 clients, unknown 0, unknown_last empty, all good.
  • A client with no IP. One phone on FBI-Surveillance-Van-2 was listed with an empty ip. It’s on the network, so it reads online; its ip tag reads bad with “the console has no IP for it”, and an alarm naming it leaves the address out instead of printing a blank one. TestUniFiAClientWithNoIPIsStillOnTheNetwork holds the driver to that exact client.
  • The stale IP the notes warned about, caught live. A TheCluster client (network 100.64.121.0/24) was listed at 100.64.120.171. That’s why nothing in HotLoop keys by IP.

Still pending, because they need somebody at the rack:

  • A real cable pull on a watched device: port_up and online should drop on the next read, and the off-the-network alarm raise after its delay. The lab proves it against the real controller; the house hasn’t yet.
  • A phone joining SCADA after it’s marked OT and accepted as it is: the unknown-device alarm should raise and name it. SCADA had nobody on it during the run.

Read-only, and repeatable:

Terminal window
HOTLOOP_TEST_UNIFI_LIVE_URL=https://100.64.110.1 \
HOTLOOP_TEST_UNIFI_LIVE_USER=hotloop-ro \
HOTLOOP_TEST_UNIFI_LIVE_PASSWORD_FILE=/path/to/password \
HOTLOOP_TEST_UNIFI_LIVE_FINGERPRINT=<the console's key pin> \
go test ./internal/driver -run TestUniFiLive -v

It logs in once, reads the site six times ten seconds apart, prints the state, WAN, PoE and site tags, and fails on a second login.

With HOTLOOP_TEST_UNIFI_LIVE_DUMP_CLIENTS=1 (and _POLLS=13, two minutes) it also prints the site’s networks and every client’s switch port or SSID, IP and how stale the console’s last_seen was at each read, watches up to three wired clients and one made-up MAC, marks the busiest network OT with nothing on its list (or the one named in HOTLOOP_TEST_UNIFI_LIVE_OT_NETWORK), and prints the c.* and n.* tags that come out. Watching and marking are HotLoop’s record, in memory for the run. Still one login, still nothing written.