Commit graph

28 commits

Author SHA1 Message Date
2db7d5f4d0 Add kiosk wall-panel control view
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>
2026-08-25 20:44:50 +00:00
f0b4ac3c41 Add device telemetry history + per-device graph view
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).
2026-08-25 17:28:45 +00:00
59a3e091d8 Fix MHI room-temp race: serialize per-unit DB writes
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.
2026-08-25 14:34:42 +00:00
d818bd1e7a Add per-TRV control panel + valve/wifi telemetry, restyle dashboard radiator icon
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).
2026-08-25 14:25:46 +00:00
807a27ca1b Fix TRV status ingestion: subscribe to live shellies/+/status topic
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.
2026-08-25 14:09:38 +00:00
1a06a860f5 Fix Daikin driver: use node:http instead of fetch() — undici gets 0-byte bodies
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>
2026-08-25 12:12:38 +00:00
373f66ef5c Add Daikin local-API driver — MAC-matched subnet sweep instead of UDP broadcast
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>
2026-08-25 12:01:58 +00:00
5fc576aabd Show weather compensation summary banner on the Dashboard
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>
2026-08-25 11:42:01 +00:00
e7a8153990 Show an explicit error when no forecast is available
The Weather Compensation hint text only ever showed the forecast peak
when the fetch succeeded — a failure (bad location, wrong Met Office
subscription, etc.) just silently showed nothing, which is exactly what
happened debugging this live. Now shows a clear red hint pointing at the
two things most likely wrong (Integrations config, backend logs) once
the initial status fetch has resolved and come back empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:28:39 +00:00
0321f0fc80 Fall back to Open-Meteo when a configured Met Office call fails
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>
2026-08-25 11:19:30 +00:00
dc8c9df719 Fix: a booking arriving in a few days showed 'booked' instead of 'vacant'
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>
2026-08-25 11:10:35 +00:00
915fc0039c Add weather compensation tiers for occupied aircon defaults
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>
2026-08-25 11:01:41 +00:00
48ffebc08d Widen booking fetch window, resolve governing booking per-night, add turnover icons/nights to dashboard
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>
2026-08-25 10:21:16 +00:00
99ec8b9747 Fix same-day turnover: pick the most-active booking per site, not just the first
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>
2026-08-25 09:55:35 +00:00
d10e4884b8 Add global occupied/vacant default templates with per-room overrides
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>
2026-08-25 09:48:28 +00:00
bed2c9e2be Dashboard: drop room-state badge, colour left border to match NewBook status colours
- Remove status badge from ZoneCard, keep left-border indicator only (with hover title for accessibility)
- Rename heating_up label from 'Heating Up' to 'Preparing'
- Recolour room states: booked=green, occupied=blue, cooling_down=purple (matches NewBook's status colours); vacant stays grey, heating_up stays amber
- Decouple rad-icon.heating from --st-occupied (now blue) to --st-heating-up (amber) so an actively-heating radiator icon still reads warm

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:24:54 +00:00
d7cbb0c452 hvac: add gateway delete (retire the old Modbus gateway config)
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>
2026-08-13 14:19:02 +00:00
7ce95a31da hvac: add device delete (retire stale mhi_modbus rows after MQTT switch)
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>
2026-08-13 13:51:41 +00:00
6325532cfe hvac: fetch MQTT credentials from Settings (drop .env secret)
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>
2026-08-13 13:42:58 +00:00
c873e4a270 hvac: native MQTT driver for MHI aircon (replaces Modbus for control/status)
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>
2026-08-13 13:31:01 +00:00
d43ed903f8 Add iOS install hint + Apple PWA meta tags, fix missing beforeinstallprompt handler
iOS Safari has no beforeinstallprompt API, so the portal's Install
button (?install=1) silently did nothing there. IosInstallHint now
shows Share -> Add to Home Screen steps on Safari, or a prompt to
switch to Safari first if opened in another iOS browser/in-app
webview (those can't install PWAs on iOS at all).

Also added apple-mobile-web-app-title + apple-touch-icon so the
home screen icon isn't a page screenshot, and added the
beforeinstallprompt handler that was missing entirely (Android
Install button did nothing either).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 14:12:12 +00:00
7f5891d029 Add PWA manifest support and icons
App had no manifest, no service worker, and no icons — not
installable as a PWA at all despite having PWA meta tags in index.html.
2026-07-29 17:28:39 +00:00
0929285651 Redesign dashboard zone card with live temps, AC status, and per-radiator state
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>
2026-07-27 20:25:50 +00:00
ab14d4dfe1 Scope automation switch to setpoints only, not the whole poll cycle
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>
2026-07-27 15:48:13 +00:00
c6e3c73ead Add master automation on/off switch to Settings
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>
2026-07-27 15:46:12 +00:00
e5c40bbb8c Add MHI aircon manual control (Phase 2, Modbus only)
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>
2026-07-27 14:43:38 +00:00
a9b5703c57 Add Phase 2 MHI Modbus register-map profile, xlsx importer, and a
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>
2026-07-27 14:19:58 +00:00
276c04f8c6 Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler
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>
2026-07-26 18:00:42 +00:00