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] }) // DELETE /api/mhi-gateways/:id — remove a gateway config entirely. Main use: // retiring a gateway after it's switched to native MQTT (see lib/mqtt.js) and // its Modbus register-map config is no longer needed. Devices that reference // this gateway (device_type='mhi_modbus') are NOT deleted — mhi_gateway_id // just goes NULL (ON DELETE SET NULL) — delete those rows separately via // DELETE /api/devices/:id if they're being retired too. app.delete('/api/mhi-gateways/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => { const { rows } = await pool.query('SELECT id FROM mhi_gateways WHERE id = $1', [req.params.id]) if (!rows.length) return reply.status(404).send({ error: 'Gateway not found' }) await pool.query('DELETE FROM mhi_gateways WHERE id = $1', [req.params.id]) return { ok: true } }) // 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] }) }