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
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]
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue