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>
This commit is contained in:
parent
a9b5703c57
commit
e5c40bbb8c
14 changed files with 935 additions and 20 deletions
|
|
@ -14,6 +14,7 @@
|
|||
"fastify": "^4.28.1",
|
||||
"jose": "^5.9.6",
|
||||
"luxon": "^3.5.0",
|
||||
"modbus-serial": "^8.0.25",
|
||||
"mqtt": "^5.10.3",
|
||||
"pg": "^8.13.1",
|
||||
"sharp": "^0.33.0",
|
||||
|
|
|
|||
|
|
@ -104,8 +104,43 @@ export async function initDb() {
|
|||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS activity_log_zone_idx ON activity_log (zone_id, created_at DESC);
|
||||
|
||||
-- MHI Modbus TCP gateways (Intesis SuperLink -> Modbus, see
|
||||
-- lib/drivers/mhi-profiles/intesis-mhi-modbus.js). Connection details are
|
||||
-- UI config; the per-unit register map is ground truth from a MAPS "Excel
|
||||
-- signals file" import (lib/mhi-xlsx-import.js), never derived from
|
||||
-- room/IU at runtime. imported_units stages the last parsed export for
|
||||
-- staff review — importing never auto-creates zone_devices rows, staff
|
||||
-- explicitly assign each unit to a zone (see /units/:unitIndex/assign).
|
||||
CREATE TABLE IF NOT EXISTS mhi_gateways (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INT NOT NULL DEFAULT 502,
|
||||
slave_id INT NOT NULL DEFAULT 1,
|
||||
address_base INT NOT NULL DEFAULT 0,
|
||||
imported_filename TEXT,
|
||||
imported_units JSONB,
|
||||
imported_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`)
|
||||
|
||||
// zone_devices predates MHI support — nullable columns added as an ALTER,
|
||||
// same pattern as auth/calendar/wages db.js (ADD COLUMN IF NOT EXISTS keeps
|
||||
// initDb() idempotent without a separate migrations runner).
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_gateway_id INT REFERENCES mhi_gateways(id) ON DELETE SET NULL`)
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_unit_index INT`)
|
||||
// Informational SuperLink IU hint only, shown in the UI — NEVER used for register math
|
||||
// (the register base can only be known from an actual MAPS import; see the profile file).
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_iu_address INT`)
|
||||
// The resolved per-unit `fields` object straight from the MAPS import for this device
|
||||
// (e.g. { setpoint: { address, active, readWrite }, roomTemp: {...}, ... }) — stored
|
||||
// directly rather than re-derived from profile+index at runtime, so there's no drift
|
||||
// between what was imported and what the driver assumes.
|
||||
await pool.query(`ALTER TABLE zone_devices ADD COLUMN IF NOT EXISTS mhi_register_map JSONB`)
|
||||
|
||||
await seedDefaults()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { statusRoutes } from './routes/status.js'
|
|||
import { overrideRoutes } from './routes/override.js'
|
||||
import { configRoutes } from './routes/config.js'
|
||||
import { settingsRoutes } from './routes/settings.js'
|
||||
import { mhiGatewayRoutes } from './routes/mhi-gateways.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
|
||||
|
|
@ -41,6 +42,7 @@ await app.register(statusRoutes)
|
|||
await app.register(overrideRoutes)
|
||||
await app.register(configRoutes)
|
||||
await app.register(settingsRoutes)
|
||||
await app.register(mhiGatewayRoutes)
|
||||
|
||||
try {
|
||||
await initDb()
|
||||
|
|
|
|||
218
backend/src/lib/drivers/mhi-modbus.js
Normal file
218
backend/src/lib/drivers/mhi-modbus.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// Driver interface implementation for the Intesis MHI SuperLink -> Modbus TCP
|
||||
// gateway (see lib/drivers/mhi-profiles/intesis-mhi-modbus.js for the fully
|
||||
// hardware-verified register map/encodings, and lib/mhi-xlsx-import.js for how
|
||||
// a MAPS export becomes a device's stored `mhi_register_map`).
|
||||
//
|
||||
// Same discover()/getStatus(externalRef)/setTarget(externalRef, {mode, tempC})
|
||||
// shape as every other driver (trv.js, homeassistant.js) EXCEPT discover(),
|
||||
// which takes a gatewayConfig argument instead of none — Modbus has no
|
||||
// broadcast "who's out there" discovery, so there's no single generic scan to
|
||||
// run; discover() here is really a connection test against ONE already-known
|
||||
// gateway (called from routes/mhi-gateways.js's test-connection route, not
|
||||
// from the generic /api/devices/discover flow). The actual units always come
|
||||
// from a MAPS register-map import, never from this call.
|
||||
import ModbusRTU from 'modbus-serial'
|
||||
import { pool } from '../../db.js'
|
||||
import { intesisMhiModbus as profile, unitRegister } from './mhi-profiles/intesis-mhi-modbus.js'
|
||||
|
||||
export const DEVICE_TYPE = 'mhi_modbus'
|
||||
|
||||
// Modbus TCP requests must be serialised, never sent concurrently down one
|
||||
// connection — this per-gateway promise chain makes sure two overlapping
|
||||
// getStatus()/setTarget() calls against the same gateway queue up instead of
|
||||
// racing on the wire. Keyed by gateway id.
|
||||
const gatewayQueues = new Map()
|
||||
|
||||
function enqueue(gatewayId, fn) {
|
||||
const prev = gatewayQueues.get(gatewayId) || Promise.resolve()
|
||||
const settled = prev.then(fn, fn) // run fn regardless of whether the previous op succeeded
|
||||
gatewayQueues.set(gatewayId, settled.catch(() => {})) // never let a rejection break the chain for the next caller
|
||||
return settled
|
||||
}
|
||||
|
||||
async function withClient(gateway, fn) {
|
||||
const client = new ModbusRTU()
|
||||
await client.connectTCP(gateway.host, { port: gateway.port || 502 })
|
||||
try {
|
||||
client.setID(gateway.slave_id || 1)
|
||||
client.setTimeout(5000)
|
||||
return await fn(client)
|
||||
} finally {
|
||||
await new Promise(resolve => client.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
function runOnGateway(gateway, fn) {
|
||||
return enqueue(gateway.id, () => withClient(gateway, fn))
|
||||
}
|
||||
|
||||
async function loadGatewayById(gatewayId) {
|
||||
if (!gatewayId) return null
|
||||
const { rows } = await pool.query('SELECT * FROM mhi_gateways WHERE id = $1', [gatewayId])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function loadDeviceByExternalRef(externalRef) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM zone_devices WHERE device_type = $1 AND external_ref = $2`,
|
||||
[DEVICE_TYPE, externalRef]
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Resolves the absolute wire address + profile encoding info for one field on
|
||||
// one device. Prefers the device's imported mhi_register_map (ground truth for
|
||||
// that specific unit); falls back to the profile's stride formula only if the
|
||||
// gateway has never had a MAPS export imported for it — logging a warning,
|
||||
// since the stride formula is unverified for any given unit (see the profile
|
||||
// file's long comment on why dense slot != IU != room number).
|
||||
function resolveField(device, gateway, fieldName) {
|
||||
const profileField = profile.perUnit.fields[fieldName]
|
||||
if (!profileField) throw new Error(`Unknown MHI field: ${fieldName}`)
|
||||
|
||||
const imported = device.mhi_register_map?.[fieldName]
|
||||
let address
|
||||
if (imported && imported.address != null) {
|
||||
address = (gateway.address_base ?? profile.defaults.addressBase) + imported.address
|
||||
} else {
|
||||
console.warn(
|
||||
`[mhi-modbus driver] device ${device.id}: no imported register map for field "${fieldName}" — ` +
|
||||
`falling back to the stride formula for unit index ${device.mhi_unit_index}. ` +
|
||||
`This is UNVERIFIED for this specific unit — import the gateway's MAPS export to fix.`
|
||||
)
|
||||
address = unitRegister(profile, device.mhi_unit_index, fieldName, gateway.address_base ?? profile.defaults.addressBase)
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
encoding: profileField.encoding,
|
||||
values: profileField.values || null,
|
||||
enum: profileField.enum || null,
|
||||
access: imported?.readWrite || profileField.access,
|
||||
}
|
||||
}
|
||||
|
||||
function decodeValue(field, raw) {
|
||||
const enc = profile.encodings[field.encoding] || { signed: false, scale: 1 }
|
||||
let value = raw
|
||||
if (enc.signed && value >= 0x8000) value -= 0x10000
|
||||
if (enc.scale && enc.scale !== 1) value = value / enc.scale
|
||||
|
||||
if (field.values) return field.values[raw] ?? raw
|
||||
if (field.enum) {
|
||||
const enumMap = profile.enums[field.enum]
|
||||
return enumMap ? (enumMap[raw] ?? raw) : raw
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Inverse of decodeValue — friendlyValue may be a decoded label ('heat',
|
||||
// 'on'/true) or an already-raw number; either is accepted so callers can pass
|
||||
// through UI values without needing to know the wire encoding.
|
||||
function encodeValue(field, friendlyValue) {
|
||||
const enc = profile.encodings[field.encoding] || { signed: false, scale: 1 }
|
||||
let raw
|
||||
|
||||
if (field.values) {
|
||||
if (typeof friendlyValue === 'boolean') friendlyValue = friendlyValue ? 'on' : 'off'
|
||||
const entry = Object.entries(field.values).find(([k, v]) => v === friendlyValue || k === String(friendlyValue))
|
||||
raw = entry ? Number(entry[0]) : Number(friendlyValue)
|
||||
} else if (field.enum) {
|
||||
const enumMap = profile.enums[field.enum]
|
||||
const entry = Object.entries(enumMap).find(([k, v]) => v === friendlyValue || k === String(friendlyValue))
|
||||
raw = entry ? Number(entry[0]) : Number(friendlyValue)
|
||||
} else {
|
||||
let scaled = Number(friendlyValue) * (enc.scale || 1)
|
||||
if (enc.minC != null && enc.maxC != null) {
|
||||
scaled = Math.min(Math.max(scaled, enc.minC * enc.scale), enc.maxC * enc.scale)
|
||||
}
|
||||
raw = scaled
|
||||
}
|
||||
|
||||
raw = Math.round(raw)
|
||||
if (enc.signed && raw < 0) raw += 0x10000
|
||||
return raw & 0xffff
|
||||
}
|
||||
|
||||
// Connection test against ONE already-configured gateway — reads the global
|
||||
// "Gateway Communication Status" register (confirmed live 2026-07-27, addr
|
||||
// 2995, 0 = ok). Doesn't enumerate units; that only ever comes from an import.
|
||||
export async function discover(gatewayConfig) {
|
||||
if (!gatewayConfig?.host) {
|
||||
return { ok: false, error: 'Gateway has no host configured', devices: [] }
|
||||
}
|
||||
try {
|
||||
const raw = await runOnGateway(gatewayConfig, async client => {
|
||||
const addressBase = gatewayConfig.address_base ?? profile.defaults.addressBase
|
||||
const res = await client.readHoldingRegisters(addressBase + profile.global.gatewayCommStatus.address, 1)
|
||||
return res.data[0]
|
||||
})
|
||||
const ok = raw === 0
|
||||
return {
|
||||
ok,
|
||||
devices: [],
|
||||
note: ok
|
||||
? 'Gateway reachable, comm status OK. Indoor units come from a MAPS register-map import, not discovery — use "Import register map" below.'
|
||||
: `Gateway reachable but comm status register reports a fault (raw=${raw}).`,
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message, devices: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// externalRef is `mhi:<gatewayId>:<unitIndex>` (set at assign time by
|
||||
// routes/mhi-gateways.js) — same "one string key, driver looks up the rest
|
||||
// from the DB" shape as trv.js.
|
||||
export async function getStatus(externalRef) {
|
||||
const device = await loadDeviceByExternalRef(externalRef)
|
||||
if (!device) return null
|
||||
const gateway = await loadGatewayById(device.mhi_gateway_id)
|
||||
if (!gateway) return { external_ref: externalRef, error: 'No gateway configured for this device' }
|
||||
|
||||
const fieldsToRead = ['onOff', 'mode', 'setpoint', 'fanSpeed', 'roomTemp', 'errorCode', 'compressor', 'commStatus', 'filterSign']
|
||||
const result = { external_ref: externalRef }
|
||||
try {
|
||||
await runOnGateway(gateway, async client => {
|
||||
for (const fieldName of fieldsToRead) {
|
||||
const field = resolveField(device, gateway, fieldName)
|
||||
const imported = device.mhi_register_map?.[fieldName]
|
||||
if (imported && imported.active === false) continue // field disabled on this gateway/firmware
|
||||
const res = await client.readHoldingRegisters(field.address, 1)
|
||||
result[fieldName] = decodeValue(field, res.data[0])
|
||||
}
|
||||
})
|
||||
return result
|
||||
} catch (err) {
|
||||
return { external_ref: externalRef, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// setTarget(externalRef, { mode, tempC, fanSpeed, onOff }) — writes only the
|
||||
// fields that were actually passed (FC06, write single register per field).
|
||||
export async function setTarget(externalRef, { mode, tempC, fanSpeed, onOff } = {}) {
|
||||
const device = await loadDeviceByExternalRef(externalRef)
|
||||
if (!device) return false
|
||||
const gateway = await loadGatewayById(device.mhi_gateway_id)
|
||||
if (!gateway) return false
|
||||
|
||||
const writes = []
|
||||
if (onOff !== undefined && onOff !== null) writes.push(['onOff', onOff])
|
||||
if (mode) writes.push(['mode', mode])
|
||||
if (tempC !== undefined && tempC !== null) writes.push(['setpoint', tempC])
|
||||
if (fanSpeed) writes.push(['fanSpeed', fanSpeed])
|
||||
if (!writes.length) return true
|
||||
|
||||
try {
|
||||
await runOnGateway(gateway, async client => {
|
||||
for (const [fieldName, friendlyValue] of writes) {
|
||||
const field = resolveField(device, gateway, fieldName)
|
||||
const raw = encodeValue(field, friendlyValue)
|
||||
await client.writeRegister(field.address, raw)
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(`[mhi-modbus driver] setTarget failed for device ${device.id} (${externalRef}):`, err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -26,13 +26,15 @@
|
|||
// dense position. This is why the real export is the source of truth and the
|
||||
// stored-base-per-device path is PRIMARY, not a fallback — see unitRegister.
|
||||
//
|
||||
// STILL TO CONFIRM (only observable from a live Modbus read, not a config screen):
|
||||
// - Address base: the `address` values below are the raw MAPS addresses. Some
|
||||
// Modbus masters expect a +40001 holding-register offset. `addressBase: 0`
|
||||
// here means "use the address as-is in the frame (0-based protocol address)".
|
||||
// Flip to 40001 only if an integration test shows an off-by-40001 mismatch.
|
||||
// The RELATIVE structure (stride, offsets, encodings) is unambiguous either
|
||||
// way — only the absolute base could shift.
|
||||
// CONFIRMED by a live Modbus read against the gateway (10.4.1.109, 2026-07-27,
|
||||
// via scripts/test-modbus-connection.py):
|
||||
// - Address base: MAPS addresses go on the wire AS-IS (0-based protocol
|
||||
// address, `addressBase: 0`). No +40001 offset. Unit 1 setpoint read 19.0 C
|
||||
// at addr 4 and room temp 25.5 C at addr 8; the -1 candidates read empty.
|
||||
// - Encoding verified live: signed x10 decode is correct (0x00BE=190 -> 19.0).
|
||||
// - Gateway comm status (addr 2995) read 0 (ok); framing/slave-id all correct.
|
||||
// The MHI Modbus protocol layer is now fully empirically verified — no open
|
||||
// questions remain on addressing or encoding.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const intesisMhiModbus = {
|
||||
|
|
@ -46,8 +48,7 @@ export const intesisMhiModbus = {
|
|||
defaults: {
|
||||
port: 502, // confirmed on gateway (2026-07-27)
|
||||
slaveId: 1, // confirmed: Slave Number 1, Single-Slave addressing mode
|
||||
addressBase: 0, // 0 = raw protocol address; 40001 = holding-register offset
|
||||
// (STILL to confirm via a live read — see header note)
|
||||
addressBase: 0, // CONFIRMED by live read 2026-07-27: raw 0-based, no +40001
|
||||
},
|
||||
|
||||
// Registers are 16-bit holding registers throughout.
|
||||
|
|
|
|||
|
|
@ -11,13 +11,21 @@ import { createMaintenanceAsset } from '../lib/maintenance-client.js'
|
|||
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
const PHOTO_TYPES = ['device', 'serial_plate']
|
||||
|
||||
// device_type -> driver implementing discover()/getStatus()/setTarget(). Phase 2/3
|
||||
// types (mhi_modbus, midea, daikin) are enum values only — no driver yet.
|
||||
// device_type -> driver implementing discover()/getStatus()/setTarget(). Phase 3
|
||||
// types (midea, daikin) are enum values only — no driver yet. mhi_modbus DOES
|
||||
// have a driver (lib/drivers/mhi-modbus.js) but it isn't wired into this generic
|
||||
// no-args discover flow — Modbus has no broadcast scan, and discover() there
|
||||
// needs a specific already-configured gateway, so it's only ever called from
|
||||
// routes/mhi-gateways.js's test-connection route. Units are onboarded via
|
||||
// "Gateways" (Devices page): configure a gateway, import its MAPS register map,
|
||||
// then assign units to zones — never via this Discover button.
|
||||
const DRIVERS = {
|
||||
shelly_trv: trvDriver,
|
||||
home_assistant: haDriver,
|
||||
}
|
||||
const NOT_YET_IMPLEMENTED = ['mhi_modbus', 'midea', 'daikin']
|
||||
const NOT_YET_IMPLEMENTED = ['midea', 'daikin']
|
||||
const MHI_MODBUS_NOTE = "MHI aircon units aren't found via Discover — configure a gateway and import its register " +
|
||||
'map under Devices -> Gateways, then assign units to zones there.'
|
||||
|
||||
export async function deviceRoutes(app, opts) {
|
||||
const UPLOADS_DIR = opts.uploadsDir
|
||||
|
|
@ -41,11 +49,15 @@ export async function deviceRoutes(app, opts) {
|
|||
const { device_type } = req.body || {}
|
||||
if (!device_type) return reply.status(400).send({ error: 'device_type required' })
|
||||
|
||||
if (device_type === 'mhi_modbus') {
|
||||
return reply.status(200).send({ ok: true, devices: [], note: MHI_MODBUS_NOTE })
|
||||
}
|
||||
|
||||
if (NOT_YET_IMPLEMENTED.includes(device_type)) {
|
||||
return reply.status(200).send({
|
||||
ok: true, devices: [],
|
||||
note: `${device_type} discovery isn't implemented yet — Phase 1 only covers Shelly TRVs. ` +
|
||||
`This device type exists as a column enum value ready for its Phase 2/3 driver.`,
|
||||
`This device type exists as a column enum value ready for its Phase 3 driver.`,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
149
backend/src/routes/mhi-gateways.js
Normal file
149
backend/src/routes/mhi-gateways.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { parseIntesisMapsExport } from '../lib/mhi-xlsx-import.js'
|
||||
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
|
||||
|
||||
// MHI Modbus TCP gateway configuration + register-map import. Manual control
|
||||
// only for now (see lib/drivers/mhi-modbus.js) — this never touches
|
||||
// lib/scheduler.js. Importing a MAPS export only STAGES the parsed result on
|
||||
// the gateway row for staff review; no zone_devices rows are created until an
|
||||
// explicit "Assign to Zone" per unit (see the /units/:unitIndex/assign route).
|
||||
export async function mhiGatewayRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/mhi-gateways — configured gateways + how many units are assigned from each
|
||||
app.get('/api/mhi-gateways', { preHandler: requireCap('view') }, async () => {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*)::int FROM zone_devices zd WHERE zd.mhi_gateway_id = g.id) AS assigned_count
|
||||
FROM mhi_gateways g
|
||||
ORDER BY g.name
|
||||
`)
|
||||
return rows
|
||||
})
|
||||
|
||||
// POST /api/mhi-gateways — { name, host, port, slaveId, addressBase }
|
||||
app.post('/api/mhi-gateways', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { name, host, port, slaveId, addressBase } = req.body || {}
|
||||
if (!name || !host) return reply.status(400).send({ error: 'name and host are required' })
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO mhi_gateways (name, host, port, slave_id, address_base)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[name, host, port || 502, slaveId || 1, addressBase ?? 0]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// PATCH /api/mhi-gateways/:id — edit connection details
|
||||
app.patch('/api/mhi-gateways/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT * FROM mhi_gateways WHERE id = $1', [req.params.id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Gateway not found' })
|
||||
const g = existing[0]
|
||||
const { name, host, port, slaveId, addressBase } = req.body || {}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE mhi_gateways SET
|
||||
name = $1, host = $2, port = $3, slave_id = $4, address_base = $5, updated_at = NOW()
|
||||
WHERE id = $6 RETURNING *`,
|
||||
[
|
||||
name ?? g.name, host ?? g.host, port ?? g.port,
|
||||
slaveId ?? g.slave_id, addressBase ?? g.address_base, req.params.id,
|
||||
]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// POST /api/mhi-gateways/:id/test-connection — live Modbus read of the
|
||||
// gateway comm status register (same check as scripts/test-modbus-connection.py)
|
||||
app.post('/api/mhi-gateways/:id/test-connection', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows } = await pool.query('SELECT * FROM mhi_gateways WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Gateway not found' })
|
||||
const result = await mhiDriver.discover(rows[0])
|
||||
return result
|
||||
})
|
||||
|
||||
// POST /api/mhi-gateways/:id/import — multipart .xlsx upload (same
|
||||
// @fastify/multipart pattern as devices.js's photo upload route). Parses and
|
||||
// STAGES the result on the gateway row — nothing else is written.
|
||||
app.post('/api/mhi-gateways/:id/import', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT id FROM mhi_gateways WHERE id = $1', [req.params.id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Gateway not found' })
|
||||
|
||||
let fileBuffer = null, fileName = null
|
||||
for await (const part of req.parts()) {
|
||||
if (part.type === 'file') {
|
||||
const chunks = []
|
||||
for await (const chunk of part.file) chunks.push(chunk)
|
||||
fileBuffer = Buffer.concat(chunks)
|
||||
fileName = part.filename
|
||||
}
|
||||
}
|
||||
if (!fileBuffer) return reply.status(400).send({ error: 'No file uploaded' })
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseIntesisMapsExport(fileBuffer)
|
||||
} catch (err) {
|
||||
return reply.status(400).send({ error: err.message })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE mhi_gateways SET imported_filename = $1, imported_units = $2, imported_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $3 RETURNING *`,
|
||||
[fileName, JSON.stringify(parsed), req.params.id]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// POST /api/mhi-gateways/:id/units/:unitIndex/assign — { zone_id, location }
|
||||
// creates/updates a zone_devices row (device_type='mhi_modbus') from the
|
||||
// gateway's last staged import. Pulls that unit's `fields` register map
|
||||
// straight from imported_units — this is the one-time "commit" step for a
|
||||
// unit; re-running an import + re-assigning refreshes the stored map.
|
||||
app.post('/api/mhi-gateways/:id/units/:unitIndex/assign', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows: gwRows } = await pool.query('SELECT * FROM mhi_gateways WHERE id = $1', [req.params.id])
|
||||
if (!gwRows.length) return reply.status(404).send({ error: 'Gateway not found' })
|
||||
const gateway = gwRows[0]
|
||||
|
||||
const unitIndex = parseInt(req.params.unitIndex, 10)
|
||||
if (!Number.isFinite(unitIndex)) return reply.status(400).send({ error: 'Invalid unit index' })
|
||||
|
||||
const unit = (gateway.imported_units?.units || []).find(u => u.unitIndex === unitIndex)
|
||||
if (!unit) {
|
||||
return reply.status(404).send({
|
||||
error: `Unit ${unitIndex} not found in this gateway's last import — import (or re-import) the MAPS export first.`,
|
||||
})
|
||||
}
|
||||
|
||||
const { zone_id, location } = req.body || {}
|
||||
const externalRef = `mhi:${gateway.id}:${unitIndex}`
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO zone_devices
|
||||
(device_type, external_ref, discovered_name, zone_id, location,
|
||||
mhi_gateway_id, mhi_unit_index, mhi_iu_address, mhi_register_map)
|
||||
VALUES ('mhi_modbus', $1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (device_type, external_ref) DO UPDATE SET
|
||||
zone_id = EXCLUDED.zone_id,
|
||||
location = EXCLUDED.location,
|
||||
mhi_gateway_id = EXCLUDED.mhi_gateway_id,
|
||||
mhi_unit_index = EXCLUDED.mhi_unit_index,
|
||||
mhi_iu_address = EXCLUDED.mhi_iu_address,
|
||||
mhi_register_map = EXCLUDED.mhi_register_map,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[
|
||||
externalRef,
|
||||
`MHI Unit ${unitIndex}`,
|
||||
zone_id || null,
|
||||
location || null,
|
||||
gateway.id,
|
||||
unitIndex,
|
||||
unit.iu ?? null,
|
||||
JSON.stringify(unit.fields),
|
||||
]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool, logActivity } from '../db.js'
|
||||
import { batchSetZoneTemperature } from '../lib/mqtt.js'
|
||||
import * as mhiDriver from '../lib/drivers/mhi-modbus.js'
|
||||
|
||||
// Manual force-temperature — the equivalent of the old integration's
|
||||
// newbook.force_room_temperature service. Disables auto mode for the zone so the
|
||||
|
|
@ -38,4 +39,49 @@ export async function overrideRoutes(app) {
|
|||
|
||||
return { ok: successful > 0, successful, total: devices.length, auto_mode: false }
|
||||
})
|
||||
|
||||
// GET /api/devices/:id/mhi-status — live Modbus read (onOff/mode/setpoint/
|
||||
// fanSpeed/roomTemp/errorCode/etc) for one assigned MHI aircon unit.
|
||||
app.get('/api/devices/:id/mhi-status', { preHandler: requireCap('view') }, async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'mhi_modbus'`,
|
||||
[req.params.id]
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
|
||||
const status = await mhiDriver.getStatus(rows[0].external_ref)
|
||||
if (!status || status.error) {
|
||||
return reply.status(502).send({ error: status?.error || 'Failed to read device status' })
|
||||
}
|
||||
return status
|
||||
})
|
||||
|
||||
// POST /api/devices/:id/mhi-control — manual aircon control only (no
|
||||
// scheduler wiring). Body: { onOff?, mode?, tempC?, fanSpeed? } — only the
|
||||
// fields present are written, via the mhi-modbus driver's setTarget().
|
||||
app.post('/api/devices/:id/mhi-control', { preHandler: requireCap('control') }, async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'mhi_modbus'`,
|
||||
[req.params.id]
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' })
|
||||
const device = rows[0]
|
||||
const { onOff, mode, tempC, fanSpeed } = req.body || {}
|
||||
|
||||
const ok = await mhiDriver.setTarget(device.external_ref, { onOff, mode, tempC, fanSpeed })
|
||||
|
||||
if (ok && tempC !== undefined && tempC !== null) {
|
||||
await pool.query(
|
||||
`UPDATE zone_devices SET current_target_temp = $1, target_origin = 'manual', updated_at = NOW() WHERE id = $2`,
|
||||
[tempC, device.id]
|
||||
)
|
||||
}
|
||||
|
||||
await logActivity(device.zone_id, 'device_command', {
|
||||
note: `MHI manual control on ${device.location || device.discovered_name || device.external_ref}: ${JSON.stringify({ onOff, mode, tempC, fanSpeed })}${ok ? '' : ' — FAILED'}`,
|
||||
source: 'manual',
|
||||
userEmail: req.user.email,
|
||||
})
|
||||
|
||||
return { ok }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
|
||||
MhiGateway, MhiStatus,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/hvac/api'
|
||||
|
|
@ -96,6 +97,41 @@ export function photoUrl(filePath: string): string {
|
|||
return `${BASE}/uploads${filePath}`
|
||||
}
|
||||
|
||||
// MHI Modbus gateways (manual aircon control)
|
||||
export function fetchMhiGateways(): Promise<MhiGateway[]> {
|
||||
return request('/mhi-gateways')
|
||||
}
|
||||
export function createMhiGateway(body: { name: string; host: string; port?: number; slaveId?: number; addressBase?: number }): Promise<MhiGateway> {
|
||||
return request('/mhi-gateways', { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
export function updateMhiGateway(id: number, body: Partial<{ name: string; host: string; port: number; slaveId: number; addressBase: number }>): Promise<MhiGateway> {
|
||||
return request(`/mhi-gateways/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
}
|
||||
export function testMhiGatewayConnection(id: number): Promise<{ ok: boolean; note?: string; error?: string }> {
|
||||
return request(`/mhi-gateways/${id}/test-connection`, { method: 'POST', body: JSON.stringify({}) })
|
||||
}
|
||||
export async function importMhiRegisterMap(id: number, file: File): Promise<MhiGateway> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await fetch(`${BASE}/mhi-gateways/${id}/import`, { method: 'POST', credentials: 'include', body: form })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || `Import failed: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
export function assignMhiUnit(gatewayId: number, unitIndex: number, body: { zone_id: number | null; location: string }): Promise<Device> {
|
||||
return request(`/mhi-gateways/${gatewayId}/units/${unitIndex}/assign`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
// MHI manual control (device-level — mhi_modbus rows only)
|
||||
export function fetchMhiStatus(deviceId: number): Promise<MhiStatus> {
|
||||
return request(`/devices/${deviceId}/mhi-status`)
|
||||
}
|
||||
export function sendMhiControl(deviceId: number, body: { onOff?: boolean; mode?: string; tempC?: number; fanSpeed?: string }): Promise<{ ok: boolean }> {
|
||||
return request(`/devices/${deviceId}/mhi-control`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
// Config / settings
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request('/config')
|
||||
|
|
|
|||
238
frontend/src/components/GatewaysPanel.tsx
Normal file
238
frontend/src/components/GatewaysPanel.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Wifi, WifiOff, Upload, Loader2 } from 'lucide-react'
|
||||
import type { Zone, MhiGateway } from '../types'
|
||||
import {
|
||||
fetchMhiGateways, createMhiGateway, testMhiGatewayConnection, importMhiRegisterMap, assignMhiUnit,
|
||||
} from '../api'
|
||||
|
||||
// Gateway configuration + MAPS register-map import for MHI aircon units (manual
|
||||
// control only — see backend lib/drivers/mhi-modbus.js). Importing only STAGES
|
||||
// the parsed unit list on the gateway row for review; nothing is written to
|
||||
// zone_devices until staff explicitly "Assign to Zone" a unit below.
|
||||
export default function GatewaysPanel({ zones, onAssigned }: { zones: Zone[]; onAssigned: () => void }) {
|
||||
const [gateways, setGateways] = useState<MhiGateway[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [host, setHost] = useState('')
|
||||
const [port, setPort] = useState('502')
|
||||
const [slaveId, setSlaveId] = useState('1')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const [testingId, setTestingId] = useState<number | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<number, { ok: boolean; note?: string; error?: string }>>({})
|
||||
const [importingId, setImportingId] = useState<number | null>(null)
|
||||
const [assignDrafts, setAssignDrafts] = useState<Record<string, { zoneId: string; location: string }>>({})
|
||||
const [assigningKey, setAssigningKey] = useState<string | null>(null)
|
||||
|
||||
function load() {
|
||||
fetchMhiGateways()
|
||||
.then(setGateways)
|
||||
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load gateways'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim() || !host.trim()) return
|
||||
setCreating(true); setError(''); setMsg('')
|
||||
try {
|
||||
await createMhiGateway({
|
||||
name: name.trim(), host: host.trim(),
|
||||
port: parseInt(port) || 502, slaveId: parseInt(slaveId) || 1,
|
||||
})
|
||||
setName(''); setHost(''); setPort('502'); setSlaveId('1')
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Create failed')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(id: number) {
|
||||
setTestingId(id); setError('')
|
||||
try {
|
||||
const res = await testMhiGatewayConnection(id)
|
||||
setTestResults(prev => ({ ...prev, [id]: res }))
|
||||
} catch (e) {
|
||||
setTestResults(prev => ({ ...prev, [id]: { ok: false, error: e instanceof Error ? e.message : 'Test failed' } }))
|
||||
} finally {
|
||||
setTestingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(id: number, file: File | undefined) {
|
||||
if (!file) return
|
||||
setImportingId(id); setError(''); setMsg('')
|
||||
try {
|
||||
await importMhiRegisterMap(id, file)
|
||||
setMsg('Register map imported — review the units below and assign them to zones.')
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed')
|
||||
} finally {
|
||||
setImportingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function draftFor(gatewayId: number, unitIndex: number) {
|
||||
const key = `${gatewayId}:${unitIndex}`
|
||||
return assignDrafts[key] || { zoneId: '', location: '' }
|
||||
}
|
||||
function setDraft(gatewayId: number, unitIndex: number, patch: Partial<{ zoneId: string; location: string }>) {
|
||||
const key = `${gatewayId}:${unitIndex}`
|
||||
setAssignDrafts(prev => ({ ...prev, [key]: { ...draftFor(gatewayId, unitIndex), ...patch } }))
|
||||
}
|
||||
|
||||
async function handleAssign(gatewayId: number, unitIndex: number) {
|
||||
const key = `${gatewayId}:${unitIndex}`
|
||||
const draft = draftFor(gatewayId, unitIndex)
|
||||
setAssigningKey(key); setError(''); setMsg('')
|
||||
try {
|
||||
await assignMhiUnit(gatewayId, unitIndex, {
|
||||
zone_id: draft.zoneId ? Number(draft.zoneId) : null,
|
||||
location: draft.location,
|
||||
})
|
||||
setMsg(`Unit ${unitIndex} assigned.`)
|
||||
onAssigned()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Assign failed')
|
||||
} finally {
|
||||
setAssigningKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p className="muted">Loading gateways…</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{msg && <div className="ok-banner">{msg}</div>}
|
||||
|
||||
<form onSubmit={handleCreate} className="field-row" style={{ alignItems: 'flex-end', marginBottom: 16 }}>
|
||||
<div className="field" style={{ flex: 2 }}>
|
||||
<label>Name</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Main building gateway" />
|
||||
</div>
|
||||
<div className="field" style={{ flex: 2 }}>
|
||||
<label>Host</label>
|
||||
<input type="text" value={host} onChange={e => setHost(e.target.value)} placeholder="10.4.1.109" />
|
||||
</div>
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
<label>Port</label>
|
||||
<input type="number" value={port} onChange={e => setPort(e.target.value)} />
|
||||
</div>
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
<label>Slave ID</label>
|
||||
<input type="number" value={slaveId} onChange={e => setSlaveId(e.target.value)} />
|
||||
</div>
|
||||
<div className="field" style={{ flex: 'none' }}>
|
||||
<button className="btn btn-primary" type="submit" disabled={creating}>
|
||||
<Plus size={14} strokeWidth={1.75} /> Add gateway
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{gateways.length === 0 ? (
|
||||
<div className="empty-state">No MHI gateways configured yet — add one above.</div>
|
||||
) : (
|
||||
gateways.map(gw => {
|
||||
const test = testResults[gw.id]
|
||||
const units = gw.imported_units?.units || []
|
||||
return (
|
||||
<div key={gw.id} className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{gw.name}</div>
|
||||
<div className="muted" style={{ fontSize: 12 }}>
|
||||
{gw.host}:{gw.port} · slave {gw.slave_id} · {gw.assigned_count} unit{gw.assigned_count === 1 ? '' : 's'} assigned
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{test && (
|
||||
test.ok
|
||||
? <span className="badge" style={{ background: 'var(--health-healthy)' }}><Wifi size={12} strokeWidth={1.75} /> OK</span>
|
||||
: <span className="badge" style={{ background: 'var(--health-unresponsive)' }}><WifiOff size={12} strokeWidth={1.75} /> {test.error || 'Fault'}</span>
|
||||
)}
|
||||
<button className="btn btn-sm" disabled={testingId === gw.id} onClick={() => handleTest(gw.id)}>
|
||||
{testingId === gw.id ? <Loader2 size={13} strokeWidth={1.75} className="spin" /> : <Wifi size={13} strokeWidth={1.75} />}
|
||||
Test Connection
|
||||
</button>
|
||||
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{importingId === gw.id ? <Loader2 size={13} strokeWidth={1.75} className="spin" /> : <Upload size={13} strokeWidth={1.75} />}
|
||||
Import register map
|
||||
<input
|
||||
type="file" accept=".xlsx" style={{ display: 'none' }}
|
||||
onChange={e => { handleImport(gw.id, e.target.files?.[0]); e.target.value = '' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{test?.note && <p className="field-hint" style={{ marginTop: 6 }}>{test.note}</p>}
|
||||
|
||||
{gw.imported_filename && (
|
||||
<p className="field-hint" style={{ marginTop: 8 }}>
|
||||
Last import: {gw.imported_filename} ({new Date(gw.imported_at!).toLocaleString()}) — {units.length} unit{units.length === 1 ? '' : 's'} found
|
||||
{gw.imported_units?.unmatchedRows?.length ? `, ${gw.imported_units.unmatchedRows.length} unmatched row(s)` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{units.length > 0 && (
|
||||
<div className="table-wrap" style={{ marginTop: 10 }}>
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unit</th>
|
||||
<th>IU hint</th>
|
||||
<th>Fields</th>
|
||||
<th>Zone</th>
|
||||
<th>Location</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{units.map(u => {
|
||||
const draft = draftFor(gw.id, u.unitIndex)
|
||||
const key = `${gw.id}:${u.unitIndex}`
|
||||
return (
|
||||
<tr key={u.unitIndex}>
|
||||
<td>Unit {u.unitIndex}</td>
|
||||
<td>{u.iu ?? '—'}</td>
|
||||
<td>{Object.keys(u.fields).length}</td>
|
||||
<td>
|
||||
<select value={draft.zoneId} onChange={e => setDraft(gw.id, u.unitIndex, { zoneId: e.target.value })}>
|
||||
<option value="">Unassigned</option>
|
||||
{zones.map(z => <option key={z.id} value={z.id}>{z.name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text" value={draft.location} placeholder="bedroom / lounge…"
|
||||
onChange={e => setDraft(gw.id, u.unitIndex, { location: e.target.value })}
|
||||
style={{ width: 110, border: '1px solid var(--card-border)', borderRadius: 6, padding: '4px 6px', fontSize: 12.5 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-sm" disabled={assigningKey === key} onClick={() => handleAssign(gw.id, u.unitIndex)}>
|
||||
{assigningKey === key ? 'Assigning…' : 'Assign to Zone'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
frontend/src/components/MhiControlPanel.tsx
Normal file
102
frontend/src/components/MhiControlPanel.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Power, Minus, Plus, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { MHI_MODES, MHI_FAN_SPEEDS } from '../types'
|
||||
import { fetchMhiStatus, sendMhiControl } from '../api'
|
||||
|
||||
// Manual on/off + mode + setpoint + fan speed controls for one assigned MHI
|
||||
// aircon unit (mhi_modbus). No NewBook/scheduler integration — every change
|
||||
// here is an explicit staff action via POST /devices/:id/mhi-control.
|
||||
export default function MhiControlPanel({ deviceId, canControl }: { deviceId: number; canControl: boolean }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
const [onOff, setOnOff] = useState(false)
|
||||
const [mode, setMode] = useState<string>('auto')
|
||||
const [setpoint, setSetpoint] = useState(21)
|
||||
const [fanSpeed, setFanSpeed] = useState<string>('medium')
|
||||
const [roomTemp, setRoomTemp] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
setLoading(true); setError('')
|
||||
fetchMhiStatus(deviceId)
|
||||
.then(s => {
|
||||
setOnOff(s.onOff === 'on')
|
||||
if (s.mode) setMode(s.mode)
|
||||
if (s.setpoint != null) setSetpoint(s.setpoint)
|
||||
if (s.fanSpeed) setFanSpeed(s.fanSpeed)
|
||||
setRoomTemp(s.roomTemp ?? null)
|
||||
})
|
||||
.catch(e => setError(e instanceof Error ? e.message : 'Failed to read device status'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(load, [deviceId])
|
||||
|
||||
async function apply(patch: Partial<{ onOff: boolean; mode: string; tempC: number; fanSpeed: string }>) {
|
||||
setApplying(true); setError('')
|
||||
try {
|
||||
const res = await sendMhiControl(deviceId, patch)
|
||||
if (!res.ok) setError('Device did not accept the command')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Command failed')
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p className="muted" style={{ fontSize: 12.5 }}>Reading aircon status…</p>
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{error && <div className="error-banner" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
disabled={!canControl || applying}
|
||||
onClick={() => { const next = !onOff; setOnOff(next); apply({ onOff: next }) }}
|
||||
style={onOff ? { background: 'var(--gold)', borderColor: 'var(--gold)', color: 'var(--navy)' } : undefined}
|
||||
>
|
||||
<Power size={13} strokeWidth={1.75} /> {onOff ? 'On' : 'Off'}
|
||||
</button>
|
||||
|
||||
<select
|
||||
disabled={!canControl || applying}
|
||||
value={mode}
|
||||
onChange={e => { setMode(e.target.value); apply({ mode: e.target.value }) }}
|
||||
>
|
||||
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
|
||||
</select>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
className="btn btn-sm" disabled={!canControl || applying}
|
||||
onClick={() => { const next = Math.max(16, setpoint - 0.5); setSetpoint(next); apply({ tempC: next }) }}
|
||||
>
|
||||
<Minus size={12} strokeWidth={1.75} />
|
||||
</button>
|
||||
<span style={{ minWidth: 48, textAlign: 'center', fontWeight: 600, fontSize: 13 }}>{setpoint.toFixed(1)}°C</span>
|
||||
<button
|
||||
className="btn btn-sm" disabled={!canControl || applying}
|
||||
onClick={() => { const next = Math.min(30, setpoint + 0.5); setSetpoint(next); apply({ tempC: next }) }}
|
||||
>
|
||||
<Plus size={12} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
disabled={!canControl || applying}
|
||||
value={fanSpeed}
|
||||
onChange={e => { setFanSpeed(e.target.value); apply({ fanSpeed: e.target.value }) }}
|
||||
>
|
||||
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
|
||||
</select>
|
||||
|
||||
{roomTemp != null && <span className="muted" style={{ fontSize: 12 }}>Room: {roomTemp.toFixed(1)}°C</span>}
|
||||
|
||||
<button className="btn btn-sm" disabled={loading} onClick={load} title="Refresh live status">
|
||||
{applying ? <Loader2 size={13} strokeWidth={1.75} className="spin" /> : <RefreshCw size={13} strokeWidth={1.75} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { ZoneStatus, ActivityEntry } from '../types'
|
|||
import { ROOM_STATE_LABELS, can } from '../types'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { updateZone, overrideZone, fetchActivity } from '../api'
|
||||
import MhiControlPanel from './MhiControlPanel'
|
||||
|
||||
export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
||||
zone: ZoneStatus
|
||||
|
|
@ -88,7 +89,8 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
|||
<div className="section-title">Devices</div>
|
||||
{zone.devices.length === 0 && <p className="muted">No devices mapped to this zone yet — assign some on the Devices page.</p>}
|
||||
{zone.devices.map(d => (
|
||||
<div key={d.id} className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div key={d.id} className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.location || d.discovered_name || d.external_ref}</div>
|
||||
<div className="muted" style={{ fontSize: 12 }}>
|
||||
|
|
@ -98,6 +100,8 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
|||
</div>
|
||||
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
|
||||
</div>
|
||||
{d.device_type === 'mhi_modbus' && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canControl && (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
import type { Device, Zone, DevicePhoto, DeviceType } from '../types'
|
||||
import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types'
|
||||
import DevicePhotoUpload from '../components/DevicePhotoUpload'
|
||||
import GatewaysPanel from '../components/GatewaysPanel'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import { can } from '../types'
|
||||
|
||||
|
|
@ -106,6 +107,13 @@ export default function Devices() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<>
|
||||
<div className="section-title">MHI Aircon Gateways</div>
|
||||
<GatewaysPanel zones={zones} onAssigned={load} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="section-title">Mapped & Discovered Devices</div>
|
||||
{devices.length === 0 ? (
|
||||
<div className="empty-state">No devices discovered yet — run a discover scan above.</div>
|
||||
|
|
|
|||
|
|
@ -73,8 +73,71 @@ export interface Device extends DeviceSummary {
|
|||
photo_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
mhi_gateway_id?: number | null
|
||||
mhi_unit_index?: number | null
|
||||
mhi_iu_address?: number | null
|
||||
mhi_register_map?: Record<string, MhiRegisterField> | null
|
||||
}
|
||||
|
||||
// ── MHI Modbus gateways (manual aircon control) ──────────────────────────
|
||||
export interface MhiRegisterField {
|
||||
address: number
|
||||
active: boolean
|
||||
readWrite: string | null
|
||||
}
|
||||
|
||||
export interface MhiImportedUnit {
|
||||
unitIndex: number
|
||||
iu: number | null
|
||||
ou: number | null
|
||||
fields: Record<string, MhiRegisterField>
|
||||
}
|
||||
|
||||
export interface MhiImportResult {
|
||||
meta: {
|
||||
projectName: string | null
|
||||
mapsVersion: string | null
|
||||
internalProtocol: string | null
|
||||
externalProtocol: string | null
|
||||
timestamp: string | null
|
||||
}
|
||||
global: Record<string, MhiRegisterField>
|
||||
units: MhiImportedUnit[]
|
||||
unmatchedRows: { row: number; description: string }[]
|
||||
}
|
||||
|
||||
export interface MhiGateway {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
slave_id: number
|
||||
address_base: number
|
||||
imported_filename: string | null
|
||||
imported_units: MhiImportResult | null
|
||||
imported_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
assigned_count: number
|
||||
}
|
||||
|
||||
export interface MhiStatus {
|
||||
external_ref: string
|
||||
onOff?: string
|
||||
mode?: string
|
||||
setpoint?: number
|
||||
fanSpeed?: string
|
||||
roomTemp?: number
|
||||
errorCode?: number
|
||||
compressor?: string
|
||||
commStatus?: string
|
||||
filterSign?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const MHI_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const
|
||||
export const MHI_FAN_SPEEDS = ['low', 'medium', 'high', 'powerful'] as const
|
||||
|
||||
export interface DevicePhoto {
|
||||
id: number
|
||||
device_id: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue