routes/override.js and ac-control.js's commandAcDevice() got a daikin branch when the
driver was built, but scheduler.js's own separate AC_DEVICE_TYPES filter (used for
automatic occupied/vacant scheduling at room state transitions) was never updated —
so every scheduler tick for a zone with only a daikin device logged "No devices to
command" and silently skipped it. Manual control worked fine (different code path),
which is why it wasn't caught until a real unit got stuck on after a manual test.
Note: this does NOT retroactively fix a unit already stuck from a missed transition —
the scheduler only applies at state transitions, not continuously, so a currently-on
unit needs a manual off (or the next real transition) to actually turn off.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Full-screen /kiosk route, separate from the staff SPA: dedicated long-lived
kiosk login (own password, hashed, mints its own hnf_session cookie locally
via CENTRAL_AUTH_SECRET -- no central auth changes needed), scrollable grid
of small mode/setpoint blocks per live-controllable device (shelly_trv,
mhi_mqtt, daikin), tap-to-edit modal reusing the existing per-type control
panels, and a type-aware bulk multiselect (separate TRV/AC sections, only
applied to matching selected devices, TRV commands staggered).
- backend/src/routes/kiosk.js: public POST /api/kiosk/login
- backend/src/routes/settings.js: admin set/check kiosk password (bcrypt)
- backend/src/routes/config.js: exclude/protect kiosk_password_hash
- frontend/src/pages/Kiosk.tsx + components/Kiosk*: the kiosk UI
- reuses GET /api/status for the combined device list -- no new status
endpoint needed since the kiosk token now also carries the 'view' cap
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
activity_log only ever recorded state transitions/overrides/sync/errors —
useful for "what did the app decide" but not "what was the room actually
doing" when a guest/staff complaint comes in after the fact.
Backend:
- New device_readings time-series table (room_temp, target_temp, mode,
power_state, fan_speed, valve_position, heating_active), throttled to
~1 row/device/minute via recordReading() in db.js regardless of how
often a caller fires — MQTT pushes can be far chattier than that.
- Wired into every place zone_devices' live-status cache already gets
updated: ac-poller.js, daikin-poller.js, and mqtt.js's TRV
(handleStatusBlob/handleInfo) and native-MQTT MHI (writeMhiState)
handlers.
- 4-day retention by default (config key history_retention_days),
pruned every 6h by the new history-pruner.js.
- GET /api/devices/:id/history?hours=96 to read it back.
Frontend:
- DeviceHistoryModal: room temp vs setpoint line chart, plus a
valve-position chart (TRVs) or fan-speed step chart (AC), plus a
diffed mode/power/fan change log — 24h/4d/7d range picker. Uses
recharts, matching the convention already established in
utilities/wages/reports.
- Graph icon on each device in both ZoneDetailModal and the Devices
page opens it.
Side effect: frontend/package-lock.json was missing vite-plugin-pwa's
whole dependency tree even though package.json already declared it —
pre-existing drift, reconciled by this npm install (unrelated to the
recharts addition, just came along with it).
handleMhiStatus() rewrites the whole cached row from an in-memory cumulative
state object on every single signal message, via an unawaited pool.query()
per message. When several retained signals for the same unit arrive in a
tight burst (e.g. right after a broker reconnect), their DB writes can
complete out of dispatch order — an older write, captured before a later
tempAmbientSts update was known, could land in Postgres AFTER a newer write
that already had the correct room temp, silently reverting it to stale/null.
User reported AC room temp "randomly" missing from the dashboard while the
correct value was visible in the broker's retained topic — exactly this
symptom, not an MQTT delivery gap.
Fixed by chaining each unit's writes through a per-externalRef promise queue
(mhiWriteChain) so completion order can never diverge from dispatch order.
state is read at actual write time, not snapshotted early, so a queued write
still picks up whatever's freshest by the time its turn comes up.
Backend:
- New valve_position column, populated from shellies/<id>/info alongside
wifi_rssi (was already a column, never actually written to).
- New GET/POST /api/devices/:id/trv-status and /trv-control routes, mirroring
the mhi-status/mhi-control and daikin-status/daikin-control pattern. Control
goes through the existing setTargetWithRetry() reliability pipeline.
Frontend:
- New TrvControlPanel.tsx: +/- setpoint control debounced 1s after the last
click before firing a command (these are battery/deep-sleep valves going
through a real retry/backoff pipeline, not a cheap register write like MHI/
Daikin), plus room temp, valve position %, wifi signal, and a live "time
since last update" readout. Wired into ZoneDetailModal for shelly_trv rows.
- ZoneCard dashboard tile: radiator icon now just shows red when heating_active
(valve open) / grey when idle, with only the target setpoint alongside it
(dropped the room-temp half, redundant with the zone-level temp line above).
The firmware actually running on the deployed TRVs (SHTRV-01 v2.2.4,
confirmed against live hardware) publishes a combined status blob on
shellies/<id>/status, not the per-field shellies/<id>/thermostat/0/target_t
topic handleStatus() was written against — so current_target_temp was
permanently NULL and the dashboard modal always showed "—".
Adds handleStatusBlob() to parse the real payload shape and populate
current_target_temp/current_room_temp/battery_pct. This firmware also has
no guest-vs-automation 'source' field, so guest detection is redesigned to
diff an incoming target_t against a new last_commanded_target_t column,
stamped by publishTarget() right before every command is sent. A mismatch
means the guest changed it via the dial; a match means it's an echo of our
own automation.
Old handleStatus()/topicMatchesStatus() path left in place, dormant —
harmless if some future firmware does use per-field topics.
TRV command topic (shellies/<id>/thermostat/0/command/target_t) is still
unverified against live hardware — flagged in code comments.
Live-tested against the real unit (10.4.2.6, 2026-08-25): a full-subnet sweep came
back with 0 units found despite the device being reachable and fast (~150ms). Root
cause isolated via a series of in-container diagnostics, not a timeout or CIDR-math
bug — Node's fetch() (undici) reliably read a 0-byte body from this adapter's
embedded webserver, which sends a connection-close-delimited response (no
Content-Length, HTTP/1.0-style) rather than a Content-Length/chunked body. wget,
curl, and node:http all read the real ~400-byte body fine; fetch() alone did not.
Switched every request in lib/drivers/daikin.js (probe, get_control_info,
get_sensor_info, set_control_info) from fetch() to a small node:http-based
httpGet() helper with Connection: close, and left a comment warning not to
"simplify" this back to fetch(). Bumped SWEEP_TIMEOUT_MS 500ms -> 800ms for
headroom (confirmed real round-trip is ~150-180ms, so this was never the actual
problem, but a little slack is cheap).
node --check clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Daikin BRP069/BRP072-class units are WiFi/DHCP with no fixed IP. hvac's backend
runs as a Docker container on its own bridge network (not directly on the LXC's
admin-VLAN NIC), so UDP broadcast discovery risked silently failing to egress —
instead, every unit is identified by MAC (external_ref), and a background poller
periodically HTTP-sweeps the configured subnet CIDR and matches replies by MAC,
self-healing zone_devices.device_ip whenever a lease changes. Only needs plain
outbound HTTP, same path already proven by the MHI Modbus TCP driver.
- lib/drivers/daikin.js: discover()/getStatus()/setTarget() against the local
/common/basic_info + /aircon/get_control_info + /aircon/set_control_info API
(confirmed live against firmware 4_2_303, 2026-08-25). Mode/fan-rate encoding
taken from community reverse-engineering, not yet verified against a live
setTarget() call — flagged in comments for first real control test.
- lib/daikin-poller.js: 60s status poll (cached IP) + 5min subnet resweep
(self-heal IP + auto-register new units, never auto-assigned to a zone).
- lib/device-registry.js: shared discover-upsert, extracted so both the manual
Discover button and the background sweep record device_ip the same way.
- ac-control.js / routes/override.js: daikin-status/daikin-control endpoints
and commandAcDevice() branch, mirroring the existing MHI control path.
- Settings page: new "Daikin subnet (CIDR)" field (daikin_subnet_cidr config
key) — sweeps do nothing until this is set.
- Frontend: DaikinControlPanel.tsx (on/off/mode/setpoint/fan), wired into
ZoneDetailModal; daikin moved from NOT_YET_IMPLEMENTED to a real driver.
Backend node --check and frontend tsc --noEmit both clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New lib/ac-template.js factors the "which occupied AC template applies
right now" resolution out of scheduler.js (dedupes the tier-pick loop
that used to live separately in scheduler.js and status.js) into a
shared resolveOccupiedAcTier()/resolveGlobalOccupiedAcTemplate() pair.
GET /api/status now returns active_ac_template alongside weather_peak_c.
Dashboard shows a small banner above the zone grid: "Forecast high
today: XC" + "Aircon default: ON, 20C, High fan — Warm tier" — so staff
don't have to visit Settings to see what today's weather tier resolved
to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
getPeakTempC() previously only tried Open-Meteo when NO Met Office api_key
was set at all — if a key was set but the call itself failed (wrong
DataHub product subscription, outage, etc.) the whole fetch went dark
with no fallback, so weather compensation silently stopped surfacing
anywhere. Root cause hit live: fetchFromMetOffice() calls the "Global
Spot" deterministic product, which is a separate DataHub subscription
from "Site-Specific Blended Probabilistic Forecast" — subscribing to the
wrong one 403s. Now any Met Office failure degrades to Open-Meteo instead
of returning nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
resolveGoverningBooking() was letting an upcoming (not-today) booking govern colour,
so any room with something booked within the 7-day lookahead showed green 'Booked'
regardless of how far off the arrival was. 'booked' should only apply when someone's
actually arriving tonight.
Now a booking only governs state/colour when it covers today (arriving today,
mid-stay, or departing today) — otherwise the room is genuinely vacant. The soonest
upcoming booking (if any) is surfaced separately as vacant_nights, a countdown to
the next arrival, purely informational and no longer tied to colour. Dashboard shows
'Vacant N nights' when something's booked within the window, or 'Vacant 7+ nights'
when nothing was found in the fetched window at all.
New zone_state.vacant_nights column, threaded through saveZoneState/pollOnce/status.js.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Warm/hot/heatwave tiers (ascending peak_above_c thresholds) override the
global ac_occupied template when today's forecast peak clears one, picked
once at the occupied-state transition so a guest's later manual adjustment
is never stomped. New lib/weather.js fetches the peak via Met Office
DataHub (if configured in settings) or Open-Meteo (no key needed) as a
fallback, TTL-cached with a stale-fallback window on fetch failure.
Zone-level per-field overrides still apply on top of a picked tier.
Thresholds/templates live in hvac_defaults.ac_occupied_tiers (existing
JSONB config blob, no migration) and are editable on the Settings page's
new Weather Compensation table. Hotel lat/lon and the optional Met Office
API key live centrally in the settings app (hotel_location/met_office
integrations), not hvac-local, for reuse by other apps later.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Backend:
- fetchBookings() window widened from today-only to yesterday..+7 days, so a
departing booking (arrived any number of nights ago) and upcoming bookings are
both in view — the previous today-only fetch couldn't see a departing guest's
arrival date at all.
- New resolveGoverningBooking(): replaces the single-booking-per-site pick with a
two-tier resolution — (1) any non-cancelled booking whose stay covers today,
preferring one not yet marked 'departed' (so a departing guest keeps governing
blue->purple right up until checkout is confirmed, even with a same-day arrival
also active); (2) otherwise the soonest upcoming booking, purely for colour
('booked' ahead of its own prep window). Also computes booking_kind
(arrival/departure/stayover), current/total night count, and a has_next_booking
turnover hint (for departure days with another booking already lined up).
- determineRoomState() no longer forces 'vacant' for a future-dated arrival — that
case is now handled by resolveGoverningBooking() deciding whether a booking is
relevant to today at all; once picked as governing it always resolves to at
least 'booked'.
- zone_state table: new booking_kind/current_night/total_nights/has_next_booking
columns, persisted every poll alongside room_state and exposed via GET /api/status.
Frontend:
- ZoneCard: room name is now followed by a takeoff (departure) / landing (arrival)
/ bed (stayover) icon plus a current/total nights figure, sourced from the new
status fields. A departure additionally shows a landing icon when has_next_booking,
hinting at a same-day turnover before the room's actually marked departed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pollOnce() previously collapsed multiple bookings overlapping today for one site
down to a single candidate before computing room_state, with a weak tie-break
(prefer 'arrived' status, else whichever the API happened to return first). On a
same-day turnover — departing guest + incoming guest both returned by NewBook's
'staying' list for the same site — if neither was marked 'arrived' yet, this could
arbitrarily lock onto the incoming guest's booking (still in 'booked', prep not
started) instead of the departing guest's (which should be 'cooling_down'), even
though the departing guest was the one actually still checked into the room.
Now every candidate booking for a site is evaluated (each zone applies its own
offsets, so this only resolves once we have the zone), and the state machine picks
whichever produces the most active result: occupied > heating_up > cooling_down >
booked > vacant. Zero behaviour change for the common single-booking-per-site case.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Settings gains a 'Default Templates' section: arrival-prep/departure-prep offsets,
a default aircon template (on/off, mode, setpoint, fan speed x occupied/vacant), and
default TRV setpoints. Every room can individually override any of these fields via
per-field 'Override' toggles in its Schedule modal, falling back to the global
default when not overridden.
Backend:
- zones table: occupied_temp/vacant_temp/heating_offset_min/cooling_offset_min are
now nullable (NULL = inherit default); existing values preserved as explicit
per-room overrides, so no room's behaviour changes on deploy. 8 new nullable
ac_occupied_*/ac_vacant_* override columns.
- New 'hvac_defaults' config key (generic config store) holds the global template.
- scheduler.js: resolves each field as zone-override ?? global-default, and now
also drives AC state automatically on occupancy transitions (previously AC was
100% manual) via the same command path the manual control panel uses.
- lib/ac-control.js: factored the mhi_mqtt/mhi_modbus command logic out of
routes/override.js so the scheduler and the manual control route share one path.
- zones.js PATCH: explicit-null-clears-override semantics for the 12 overridable
fields (distinct from omitting the key, which leaves the value unchanged).
Frontend:
- Settings.tsx: new Default Templates section matching the requested layout.
- ZoneDetailModal.tsx: per-field Override checkboxes for offsets, AC
occupied/vacant template, and TRV occupied/vacant setpoints.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was no way to remove an mhi_gateways row either, alongside the
already-fixed device-delete gap — exposed by the same Modbus->MQTT
switchover. Adds DELETE /api/mhi-gateways/:id (manage_devices cap) and a
'Delete gateway' button. Devices referencing the gateway are NOT deleted
(mhi_gateway_id just goes NULL per the existing ON DELETE SET NULL FK) —
the confirm dialog says so and points at the separate device-delete flow
for retiring the stale mhi_modbus rows too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was no way to remove a zone_devices mapping — a gap exposed by the
Modbus->MQTT switchover, which leaves the old mhi_modbus rows stale beside
the freshly auto-registered mhi_mqtt ones. Adds DELETE /api/devices/:id
(manage_devices cap, unlinks photo files then deletes; device_photos
cascade) and a 'Delete device' button in the expanded device row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getCredentials() now fetches the hvac-backend broker login from Settings'
new internal service-client endpoint (GET /internal/mqtt-client/hvac-backend,
bearer SETTINGS_SECRET) — same runtime-fetch pattern as newbook.js, no
broker secret in this app's .env. Env MQTT_* vars remain a dev-only override.
Drops the MQTT_USERNAME/PASSWORD compose passthrough added earlier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Intesis MHI gateway was switched from Modbus TCP to native MQTT mode
(2026-08-13), publishing retained per-signal JSON status to
hvac/mhi/IU<nn>/status/<signal> and subscribing to commands at
hvac/mhi/IU<nn>/cmd/<signal>Cmd. This adds an MQTT ingest + command path
alongside the now-dormant Modbus driver, under a new mhi_mqtt device type.
Backend:
- lib/mqtt.js: subscribe hvac/mhi/+/status/+, parse JSON payloads, auto-
register each unit as an unassigned mhi_mqtt zone_devices row and cache
live status (power/mode/setpoint/room temp/fan/health). Add
publishMhiCommand() for on/off/mode/setpoint/fan/vanes writes (QoS 1,
retain off — a retained command would replay stale on gateway reconnect).
Numeric mode/fan enums map index-for-index to the frontend label arrays.
- routes/override.js: mhi-status/mhi-control branch on device_type —
mhi_mqtt serves from the push-populated cache and publishes commands;
mhi_modbus keeps its synchronous register path unchanged.
- routes/devices.js: discover() note for mhi_mqtt (units self-register).
- No DB migration: existing status columns cover it.
Credentials: getCredentials() now prefers env-injected creds
(MQTT_BROKER_HOST/USERNAME/PASSWORD) over the settings fetch — the broker
hashes each dynsec client password show-once, so it can't be re-served from
settings at runtime. Compose passes the new env through.
Frontend: add mhi_mqtt to DeviceType/labels/implemented list, render
MhiControlPanel and count it as an AC device type.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds actual room-temperature capture for TRVs over MQTT and a background
Modbus poller for AC mode/fan/lock/room-temp, so the dashboard tile can show
real setpoint-vs-actual readings, an AC status block, and up to 3 radiator
icons per zone instead of just the setpoint.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Master switch should behave like each zone's own auto_mode — NewBook
polling, room-state tracking, and activity logging keep running as
normal; only the actual device setpoint commands are held back.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A single global toggle that overrides every zone's own auto_mode —
lets staff pause all HVAC automation stack-wide (e.g. during
maintenance) without touching each zone individually. Backed by the
existing config key/value store; pollOnce() short-circuits entirely
when off (no NewBook fetch, no state tracking, no device commands).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Manual on/off/mode/setpoint/fan control for MHI indoor units via the
already-verified Intesis Modbus TCP gateway, deliberately NOT wired into
the NewBook-driven scheduler yet — that stays deferred. Modbus doesn't
depend on the MQTT broker (separate infra, still unbuilt), so this can be
deployed and tested standalone.
- mhi_gateways table (connection config: host/port/slave id/address base)
+ zone_devices columns for MHI devices (gateway id, unit index, IU hint,
and the per-device register map staged from a MAPS import — ground
truth, never re-derived from room/IU at runtime)
- drivers/mhi-modbus.js: discover/getStatus/setTarget over modbus-serial,
same interface as trv.js/homeassistant.js; per-gateway request queue
since Modbus TCP requires serialised requests; falls back to the
profile's stride formula (with a loud warning) only if a unit has never
been through an import
- routes/mhi-gateways.js: gateway CRUD, live test-connection, xlsx import
(stages the parsed result for review — never auto-creates devices),
per-unit 'assign to zone' as the explicit commit step
- routes/override.js: GET .../mhi-status, POST .../mhi-control (control cap)
- frontend: GatewaysPanel (add gateway, test connection, import + preview,
assign units) and MhiControlPanel (on/off, mode, setpoint, fan) wired
into Devices.tsx / ZoneDetailModal.tsx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
standalone connection test script
Register map derived and validated from two real Intesis MAPS signals
exports (2-unit and 11-unit) taken from the physically installed gateway.
The 11-unit export overturned an earlier assumption from the config
screen's "Fixed" addressing label: register slots are dense in
config/commission order, not fixed-per-IU with gaps — so a unit's
register base must come from the imported map (or be entered manually),
never computed from room number or SuperLink IU address.
- drivers/mhi-profiles/intesis-mhi-modbus.js: register map + encodings for
the confirmed gateway (TCP, port 502, slave 1, single-slave mode),
documenting the corrected slot-vs-IU distinction
- lib/mhi-xlsx-import.js: parses an Intesis MAPS signals export into a
structured per-unit register map (0 unmatched rows against both real
samples) — this becomes the authoritative Phase 2 source of truth,
with the static profile only a pre-first-export fallback
- scripts/test-modbus-connection.py: dependency-free Modbus TCP test
(validated against a local loopback mock server) to resolve the one
remaining open question — the address-base convention — directly from
the Proxmox host, which already sits on the trusted admin VLAN
- docs/: both real MAPS export samples, preserved as provenance
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports the state machine, retry/backoff, and guest-override detection from
the retired homeassistant-newbook-heating-component, without depending on
Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following
standard stack conventions; LXC 128 (127 was already taken by utilities).
MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are
later phases/infra, not included here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>