Skip to content

A state key is missing, held, or not what you expected

Ready False StateKeyUnavailable
provider "unifi" is not reporting ups, ups.battery; holding last known state

What it means. A key the Automation needs disappeared from the observation entirely. Providers omit keys they cannot observe rather than inventing a value, so this says: the hardware publishing that key is no longer visible to the console — a UPS that dropped off, a gateway mid-reboot, a device removed from the site.

What Reactor does. It holds status.matching at its last known value and does not run onExit. This is deliberate and it is the behaviour you want: losing sight of a UPS during a power cut is not evidence that the power came back. Treating it as “no longer matching” would scale your workloads back up in the middle of the outage.

What it is not. It is not the same as the key being present with a different value — that is a normal transition. And it is not ProviderStateUnavailable, which means no state at all has been observed for the provider.

Confirm which keys vanished by comparing the message against the last full observation:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep 'state observed' | tail -1

Fix the hardware, not the Automation. Re-adopt or power up the device. The key reappears on the next poll and the condition clears on its own. If the device is gone for good, delete or rewrite the Automations that reference its keys — otherwise they hold their last matching state indefinitely, which is exactly what “held” means.

A worked trap. The UPS keys are only published when a UniFi UPS is adopted. Writing when: {ups: on-battery} with no UPS on the site gives you an Automation that never matches and permanently reports StateKeyUnavailable. That is not a bug; it is the operator declining to guess.


2a. ObservationStale, and how old a decision is allowed to be

Section titled “2a. ObservationStale, and how old a decision is allowed to be”
Ready False ObservationStale
provider "unifi" has not been observed since 2026-08-14T09:12:41Z, past the 5m0s this
install allows; still acting on the state it last reported

What it means. The console has stopped answering. Not one key missing from a reply — that is §2 — but no successful reply at all since the timestamp in the message. A failed observation is logged and dropped, because the next poll is the recovery mechanism, so the state Reactor reports is simply the last one it got.

What Reactor does: exactly what it was doing. Nothing is released, no onExit runs, no target moves. This is deliberate and it is the behaviour you want for the same reason §2 is: the console is often unreachable because of the thing the automation is reacting to. Handing workloads back the moment Reactor loses sight of a UPS would bring them up on battery power. So the bound governs what is said, never what is done.

Two windows, and only this one is unbounded. A value that changed reaches an automation within unifi.pollInterval × that key’s debounce samples — 30 seconds for wan at the defaults, 90 for internet. A console that has gone quiet has no such window at all, which is why it is the one that has to announce itself.

How old is it? Every Automation reports the observation its decisions are being taken against, whether or not a bound is set:

Terminal window
kubectl get automation -A -o custom-columns=\
'NAME:.metadata.name,MATCHING:.status.matching,OBSERVED:.status.observedAt'

Turning the report on. It is empty by default, which means unbounded:

Terminal window
helm upgrade reactor ... --set unifi.maxObservationAge=5m

Set it against unifi.pollInterval and the debounce samples rather than in isolation. Anything under about four poll intervals reports a slow console rather than a blind operator.

Then fix the console, not the Automation. The cause is in §3 — an expired API key, a rebooted gateway, a network policy, a certificate. Every failed attempt logs it:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep 'state observation failed'

The condition clears on its own on the first successful poll; nothing has to be reset.

The fleet-wide version of the same question needs no bound and no Automation, but it does need metrics.enabled, and somebody looking:

time() - reactor_last_observation_timestamp_seconds # is Reactor still seeing anything
rate(reactor_stale_decisions_total[15m]) # was it still deciding while it was not

The shipped ReactorObservationStale alert is the first of those. The counter is the attributable half: the gauge says Reactor went blind, the counter says automations went on making decisions while it was.

What it is not. It is not ProviderStateUnavailable, which means nothing has ever been observed — a first start against a console that has never answered. An install that has been running for a week and lost its console reports this instead, and keeps its claims.

Reactor derives wan from which WAN port reports is_uplink, and cross-checks it against two signals that answer the same question independently: the interface the gateway names as its uplink, and the ISP behind the address it currently holds. When those stop agreeing, it says so rather than picking a winner:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep unifi-wan
What you see What it means What to do
The gateway's WAN signals disagree about which uplink is live is_uplink and uplink.name point at different ports. Reactor reports the is_uplink answer. Check which uplink is actually carrying traffic in the UniFi UI, and if the disagreement holds, open an issue saying which one Reactor got right
The ISP behind the uplink changed but the gateway still reports the same uplink Your traffic moved to a different carrier while wan did not move. If that was a failover, wan missed it. Open an issue — a failover wan missed is exactly the evidence the mapping still needs
The gateway changed uplink but the ISP behind it did not change wan moved without your carrier changing. Normal if both uplinks are with the same ISP; suspicious otherwise. Nothing, unless your two uplinks are with different carriers
The uplink believed to be live does not report itself as online The port Reactor thinks is carrying traffic reports something other than online in last_wan_status. Note the exact status value in an issue — only online has ever been observed, and the failed value is unknown
is_uplink does not name a single live WAN port No port claimed the uplink, or more than one did. Reactor fell back to the gateway’s uplink interface. Nothing; this is the fallback working. On a failover to a cellular backup it is the expected path for as long as cellular carries the traffic — a cellular uplink never reports is_uplink at all, so the uplink interface is the signal that resolves it (#104). On all-wired gateways it is worth reporting if it persists rather than appearing for one poll during a switchover
The health endpoint accumulated uptime on an uplink other than the one wan names A third signal, from a different endpoint, disagrees — and the strongest one, because uptime is traffic the console watched pass rather than a statement about configuration. Early releases derived the uplink this check expected from wan’s two-valued answer, so it assumed the only backup was the second uplink and fired every poll through a failover to a third; that false positive is fixed, and the check now takes the resolved uplink index. On a current release this is a genuine disagreement between passed traffic and the uplink wan names, and worth reporting in a new issue. Post the uptime_stats block alongside every wanN block’s fields

None of these stops anything: state is still published and Automations still run. They exist because a wrong mapping that says nothing is far worse than one that complains. One genuine failover — to a cellular backup — has now been observed end to end (#34 has the record), and the fallback line above accompanied the whole of it: that was the line earning its keep rather than misfiring. A wired-to-wired failover has not been observed, so on an all-wired gateway with two working uplinks these lines are still where the evidence would come from — report them in a new issue rather than on #34, which is closed.

internet: down while wan: primary is not one of these, and Reactor will never log it as one. That combination is precisely the failure mode internet exists to observe — the link is up, the uplink is unchanged, and there is no internet — so treating it as a contradiction would fire a warning on exactly the case the key was added for. If you see it, believe it: your uplink is selected and useless.

wan.quality: degraded while internet: ok is not one either. They answer different questions over different time horizons: internet is the console’s judgement about reachability right now, wan.quality is availability and latency averaged over the console’s uptime window (24 hours on the hardware it was captured from). A link that was down for twenty minutes this morning is legitimately degraded and ok at the same time for the rest of the day.


10a. internet or wan.quality never appears

Section titled “10a. internet or wan.quality never appears”

Both come from stat/health, which is a separate request from the one that produces wan, isp and the UPS keys. A console that answers one and not the other publishes the keys it can — that is the same per-key degradation as a UPS dropping off, and it is deliberate — so the two failures look different in the logs:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep -E 'unifi-health|unifi-observe'
What you see What it means
The health endpoint failed; internet and wan.quality are unavailable this poll The request failed or returned a non-200. The device keys are still being published. Check the API key has access and that the console is not mid-reboot
The www subsystem reports a status this provider does not recognise Your console uses a status string this provider has never seen. Please report it — the mapping is inferred from one capture, and this line is the evidence that would fix it
The health response carries no uptime stats for the live uplink The uptime_stats block does not have an entry for the uplink wan names. Expected mid-switchover; worth reporting if it persists
The live uplink's health entry reports no availability (at log.level=debug) The console reported the uplink but no numbers for it, so wan.quality is withheld rather than guessed at zero
Neither key ever appears, and no line above wan itself is not derivable, which withholds wan.quality too — internet should still be there. Start at §13

wifi comes from the same response and degrades the same way. It is derived from the wlan subsystem’s AP counts rather than from its status string, so:

What you see What it means
The wlan subsystem reports no AP counts (debug) num_adopted or num_disconnected is missing. Neither is read as zero, so the key is withheld
No access point is adopted (debug) Zero adopted APs — there is no WiFi here to be healthy. Not the same as ok
wifi: warning you cannot explain The debug line names the numbers: wifi wifi=warning adopted=4 disconnected=1 connected=3. One of your APs is out of contact — devices and the per-device keys say which
The console's own wlan status and the value derived from its AP counts disagree UniFi’s own wording and the counts have parted company. The counts are what wifi reports. If this fires steadily, UniFi’s warning means something the counts do not — worth reporting on #9

The same granularity applies to the UPS keys. ups.runtime is published only when the UPS reports a timeToRemain above zero, and ups.load only when it reports both an output and a non-zero budget — so a UPS that reports charge but no runtime estimate publishes ups and ups.battery and withholds ups.runtime alone. An Automation matching the withheld key goes StateKeyUnavailable and holds its claim, which during a power failure is the only safe answer: losing the estimate is not the outage ending.

If ups.runtime is missing while the UPS is plainly reporting everything else, check timeToRemain in the device record directly. 0 and -1 are both this firmware’s way of saying “no estimate”, and both are treated as one:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep 'state observed' # needs log.level=debug

10b. devices or a device.<name> key is missing or unexpected

Section titled “10b. devices or a device.<name> key is missing or unexpected”

device.<name> keys are off by default. If none of them appear, that is the default doing its job — one key per adopted device is one metric series per adopted device, so you have to ask:

Terminal window
helm upgrade ... --set unifi.devices.perDeviceKeys=true
kubectl -n reactor-system logs deploy/reactor | grep 'Per-device state keys are on'

The aggregate devices key is published either way. With log.level=debug one line per poll says what the fleet looks like and names the devices behind it:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep 'device fleet'
# device fleet devices=degraded adopted=6 offline=1 offlineDevices=ap-attic=inactive perDeviceKeys=false
What you see What it means
No devices key at all Nothing in the device list is adopted and reporting a recognisable state. No adopted device reported a recognisable state at debug level confirms it
A device you own is not in offlineDevices and has no key Skipping a device that is not adopted, or An adopted device reports no state. An absent state is never read as offline
A device reports a transient state (debug) Provisioning, upgrading or heartbeat-missed — states a healthy device passes through, recognised explicitly. It counts towards neither key on purpose: a firmware upgrade is not a fleet outage, and a device mid-provision is genuinely neither online nor offline
A device reports a state this provider does not recognise A state number this provider has never seen — the transient states above log their own V(1) line instead. It counts towards neither key on purpose. Please report the number, it is what would extend the mapping
Two or more devices share one key after slugifying their names AP 1 and ap-1 both want device.ap-1, so neither is published. Rename one on the console. devices still counts both
A key vanished and Ready=False/StateKeyUnavailable The device was renamed, removed or unadopted. Reactor holds the last known state rather than firing onExit, which is why retitling a switch does not scale a workload back up. Update the Automation to the new slug

A device.<name> key has no reactor_state_info series and never will — its key name comes from your network, so it is not a metric label. Use status.observedState on the Automation, or the debug line above.

The field it is derived from — upgradable — is not in any capture this project has, so the parser is written to the shape UniFi documents and is unverified. It is built to fail by publishing nothing rather than by publishing current:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep 'unifi-firmware' # needs log.level=debug
# No adopted device reports whether it is upgradable; firmware will not be published devicesSilent=udmpro,ups-2u

If you see that line, your console names the field something else — or does not report it — and #12 is where the finding belongs. Dump one device record and look for it:

Terminal window
curl -sk -H "X-API-KEY: $UNIFI_API_KEY" \
"$UNIFI_URL/proxy/network/api/s/default/stat/device" \
| jq '[.data[] | {name, version, upgradable, upgrade_to_firmware, model_in_eol}]'

devicesSilent in the healthy version of that line is not a problem: the field is per device type, and the devices that do answer are enough to publish the key. Nothing silent is ever assumed to be current.

10d. temperature never appears, or reports high on a cool rack

Section titled “10d. temperature never appears, or reports high on a cool rack”

Like firmware, this key is derived from fields no capture in this project contains, so start by looking at what the parser actually saw:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep 'unifi-temperature' # needs log.level=debug
# temperature temperature=normal hottestCelsius=58.5 hottestDevice=switch-48 thresholdCelsius=75 devicesInstrumented=4
What you see What it means
No adopted device reports its thermals Nothing in the fleet reports has_temperature, a reading, or overheating. A UniFi UPS genuinely has none; if a switch or an AP is adopted, the field names differ on your firmware and #11 wants to know
A device claims temperature reporting but published no reading Instrumented and silent. It keeps the key alive and contributes no number — it is not counted as 0 °C
high at a hottestCelsius that looks cool Either the console set overheating (check devicesOverheating — its verdict outranks the threshold), or the readings are not Celsius. That unit is unverified. Compare hottestCelsius against what the UniFi UI shows for the same device
normal on a rack you know is hot Your threshold is above what the hardware reports. Read hottestCelsius over a day, then set unifi.temperature.highCelsius a little above it

Change the threshold and the debounce together. temperature settles over 3 samples, and the 75 °C default assumes a normal operating range of 40–60 °C; move the threshold into that range and 90 seconds of hysteresis stops meaning anything, because the reading crosses the line and stays there.

The third parser written against fields no capture contains. Same first move:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep 'unifi-poe' # needs log.level=debug
# poe poe=ok worstUtilizationPercent=32.5 worstSwitch=switch-48 draws=switch-48=63.5/195W
What you see What it means
No adopted switch reports a readable PoE budget with an empty switchesUnreadable Nothing reports total_max_power and a port_table. A gateway, an AP and a UniFi UPS all legitimately report neither; if a PoE switch is adopted, the field names differ on your firmware and #14 wants to know
switchesUnreadable=switch-48=port3(class Class 4) of 4 powered ports report no wattage A port is powering something and will not say how much, so that switch is left out entirely rather than counted as drawing nothing. Under-counting the draw would report headroom that is not there
poe: ok on a switch you know is full Check draws against what the UniFi UI shows for the same switch. If the watts are far too low, poe_power is arriving in a form this parser did not expect — it accepts a number and a numeric string, and treats anything else as no reading
Terminal window
curl -sk -H "X-API-KEY: $UNIFI_API_KEY" \
"$UNIFI_URL/proxy/network/api/s/default/stat/device" \
| jq '[.data[] | select(.total_max_power) | {name, total_max_power,
ports: [.port_table[] | select(.poe_enable) | {port_idx, poe_power, poe_class}]}]'

That output is exactly what the parser reads. Post it on #14 — with the device name removed — if it does not match what Reactor logged.


10f. An outlet.<n> key is missing, or is not the one you expected

Section titled “10f. An outlet.<n> key is missing, or is not the one you expected”

Outlets are the one key in this batch whose fields are all in a real capture, so a missing one is usually about addressing rather than about parsing:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep 'unifi-outlets'
# outlets device=ups-2u outlets=outlet.1=on,outlet.2=on,... needs log.level=debug
# relayGroups="1=[outlet.1 outlet.2 outlet.3 outlet.4] 2=[outlet.5 outlet.6 outlet.7 outlet.8]"

The grouping line is at INFO and appears whenever the grouping is first seen or changes, so it is in the default log stream.

What you see What it means
No unifi-outlets line at all No adopted device lists any outlet. Note that the captured gateway reports "outlet_table": [] — having the field is not having outlets
The key is outlet.5 and you expected outlet.nas The outlet still carries the console’s Outlet 5 placeholder. Name it in the UniFi UI; any name of the form Outlet <number> is treated as the index spelled out, not as a name
The key was outlet.nas and is now gone You renamed the outlet. The old key vanishing is lost visibility, so the last known state is held and Ready=False reports StateKeyUnavailable — nothing fires onExit because you relabelled a socket. Point the automation at the new key
Two or more outlets are addressed by the same key Two outlets have the same name, or one is named after another’s index. Neither is published, because picking one would be arbitrary and this key names something carrying mains power. Rename one
outletsUnreadable=outlet.4=no relay_state That outlet will not say what position it is in. Absent is not off, so it publishes nothing rather than reporting an outage
More than one adopted device reports an outlet table Outlet indexes restart on every chassis, so only the first device’s outlets are published. Report it in a new issue: switching shipped for a single chassis (#23), and how a second one is addressed still has to be decided before its outlets can be published or switched

An outlet switches alone, not as a bank — settled, with one thing still open

Section titled “An outlet switches alone, not as a bank — settled, with one thing still open”

The captured UPS puts outlets 1–4 in relay_group: 1 and 5–8 in relay_group: 2, and for as long as nobody had confirmed whether the hardware switches an outlet or a whole bank, there was no write path — if the bank had been the switching unit, “turn off outlet 3” would have meant “cut outlets 1 to 4”. That question is settled: on 2026-08-15 a write against the real UPS moved outlet 8 off while outlets 5, 6 and 7 stayed on, so relay_group partitions outlets by capability rather than by what switches together, and unifi.outlet.* shipped as #23 — allowlist-gated, and the allowlist is empty by default.

Reactor still reads the movement out loud, because a second UPS model is not obliged to answer the same way. Toggle one outlet in the UniFi UI and the next line it logs is the readout:

Outlet state changed. If you are checking whether this ups switches an outlet or a whole bank, this line is the readout
moved=outlet.5=on->off relayGroup=2 movedInGroup=1 outletsInGroup=4
verdict="outlets in this group moved independently of each other"

movedInGroup=1 of 4 means outlets switch individually. 4 of 4 would mean the relay group is the switching unit on your hardware — open an issue with that line before allowing anything to switch it. What remains open is narrower, and it is #109: the outlet in the 2026-08-15 test was empty, so nobody has watched a relay actually open under load, and a console that recorded the override without driving the relay would look identical from here.


The symptom: wan read primary, the gateway failed over, and instead of backup the key vanished — StateKeyUnavailable on every Automation matching it, and a when: {wan: backup} Automation that never fired. A missing key holds the last decision (see §2), so the automation written for exactly this failover stayed quiet through it: the last thing it observed was “not matching, the primary is fine”.

On releases carrying the fix for #104 the known cause is gone: a gateway with a cellular backup reports it as a third WAN (wan3), Reactor used to decode exactly two, and on failover the live uplink matched nothing it had decoded. Every wanN the gateway reports is now collected, so that failover reads backup.

If the key still disappears on a current release, the gateway is uplinked through an interface that matches no wanN entry at all — a combination this mapping has not seen. Confirm, then report it:

Terminal window
kubectl -n reactor-system logs deploy/reactor | grep "wan will not be published"

Post the gateway’s uplink.name and the ifname of every wanN block in a new issue, with addresses removed — #104 has the history of the last combination that did this, and it is closed.


11. Reactor warns about your UniFi Network version

Section titled “11. Reactor warns about your UniFi Network version”
INFO This UniFi Network version is newer than anything Reactor has been tested against;
if state keys are missing, an incompatible API is the first thing to suspect
version=11.0.0 supported="10.x (verified on 10.5.67)"

This is a warning, not a refusal — Reactor starts and polls normally. It is here so that no gateway reporting WAN ports and no UPS found in the device list reads as an incompatibility rather than as a configuration mistake, which is what it looks like otherwise.

If everything works, nothing needs doing, and a note on #43 saying which console and version worked is worth more than the warning is. If state keys are missing, the fields the parser reads have probably moved, and a capture from your console (hack/capture-unifi.sh) is what makes that fixable — it keeps an allowlist of fields, so it is safe to run and share the result of.

Could not determine the UniFi Network version instead means the Integration API endpoint did not answer: older Network releases do not serve it, and a console that is unreachable for the first seconds of a pod’s life looks the same. Reactor retries a few times and then carries on; only the version report is lost, and the poller’s own errors tell you if the console is really unreachable.

The compatibility matrix is what these lines are checked against.