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>
172 lines
8.2 KiB
JavaScript
172 lines
8.2 KiB
JavaScript
import { mkdir, unlink, writeFile } from 'fs/promises'
|
|
import { randomUUID } from 'crypto'
|
|
import { join } from 'path'
|
|
import sharp from 'sharp'
|
|
import { requireAuth, requireCap } from '../auth.js'
|
|
import { pool, logActivity } from '../db.js'
|
|
import * as trvDriver from '../lib/drivers/trv.js'
|
|
import * as haDriver from '../lib/drivers/homeassistant.js'
|
|
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 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 = ['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
|
|
app.addHook('preHandler', requireAuth)
|
|
|
|
// GET /api/devices — every mapped + unassigned device, with zone name and photo count
|
|
app.get('/api/devices', { preHandler: requireCap('view') }, async () => {
|
|
const { rows } = await pool.query(`
|
|
SELECT zd.*, z.name AS zone_name,
|
|
(SELECT COUNT(*)::int FROM device_photos p WHERE p.device_id = zd.id) AS photo_count
|
|
FROM zone_devices zd
|
|
LEFT JOIN zones z ON z.id = zd.zone_id
|
|
ORDER BY zd.zone_id NULLS FIRST, zd.device_type, zd.discovered_name
|
|
`)
|
|
return rows
|
|
})
|
|
|
|
// POST /api/devices/discover — { device_type } -> runs that driver's discover(),
|
|
// and inserts any newly-seen device as an unassigned row (never auto-linked to a zone).
|
|
app.post('/api/devices/discover', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
|
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 3 driver.`,
|
|
})
|
|
}
|
|
|
|
const driver = DRIVERS[device_type]
|
|
if (!driver) return reply.status(400).send({ error: `Unknown device_type: ${device_type}` })
|
|
|
|
const result = await driver.discover()
|
|
if (!result.ok) return reply.status(200).send(result) // e.g. broker/HA not reachable — not a hard error
|
|
|
|
let discoveredCount = 0
|
|
for (const d of result.devices) {
|
|
const res = await pool.query(
|
|
`INSERT INTO zone_devices (device_type, external_ref, discovered_name)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (device_type, external_ref) DO UPDATE SET discovered_name = EXCLUDED.discovered_name, last_seen = NOW()
|
|
RETURNING (xmax = 0) AS inserted`,
|
|
[device_type, d.external_ref, d.discovered_name || d.external_ref]
|
|
)
|
|
if (res.rows[0].inserted) discoveredCount++
|
|
}
|
|
|
|
return { ok: true, found: result.devices.length, new: discoveredCount }
|
|
})
|
|
|
|
// PATCH /api/devices/:id — zone/location assignment (a living mapping, not a one-time wizard)
|
|
app.patch('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
|
const { rows: existing } = await pool.query('SELECT * FROM zone_devices WHERE id = $1', [req.params.id])
|
|
if (!existing.length) return reply.status(404).send({ error: 'Device not found' })
|
|
const d = existing[0]
|
|
const { zone_id, location } = req.body || {}
|
|
|
|
const { rows } = await pool.query(
|
|
`UPDATE zone_devices SET zone_id = $1, location = $2, updated_at = NOW() WHERE id = $3 RETURNING *`,
|
|
[zone_id !== undefined ? zone_id : d.zone_id, location !== undefined ? location : d.location, req.params.id]
|
|
)
|
|
return rows[0]
|
|
})
|
|
|
|
// POST /api/devices/:id/photos — multipart: file + photo_type (device | serial_plate)
|
|
app.post('/api/devices/:id/photos', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
|
const deviceId = parseInt(req.params.id)
|
|
const { rows } = await pool.query('SELECT id FROM zone_devices WHERE id = $1', [deviceId])
|
|
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
|
|
|
|
let fileData = null, photoType = 'device'
|
|
for await (const part of req.parts()) {
|
|
if (part.type === 'file') {
|
|
if (!ALLOWED_IMAGES.includes(part.mimetype)) {
|
|
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
|
|
}
|
|
const chunks = []
|
|
for await (const chunk of part.file) chunks.push(chunk)
|
|
const raw = Buffer.concat(chunks)
|
|
|
|
// Auto-rotate (phone EXIF), resize to 1800px max, re-encode as JPEG — same as maintenance's task_photos.
|
|
const processed = await sharp(raw)
|
|
.rotate()
|
|
.resize(1800, 1800, { fit: 'inside', withoutEnlargement: true })
|
|
.jpeg({ quality: 82, progressive: true })
|
|
.toBuffer()
|
|
|
|
const filename = randomUUID() + '.jpg'
|
|
const dir = join(UPLOADS_DIR, 'devices', String(deviceId))
|
|
await mkdir(dir, { recursive: true })
|
|
await writeFile(join(dir, filename), processed)
|
|
fileData = { originalName: part.filename, savedAs: filename, size: processed.length }
|
|
} else {
|
|
const val = await part.value
|
|
if (part.fieldname === 'photo_type' && PHOTO_TYPES.includes(String(val))) photoType = String(val)
|
|
}
|
|
}
|
|
if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' })
|
|
|
|
const filePath = `/devices/${deviceId}/${fileData.savedAs}`
|
|
const { rows: ins } = await pool.query(
|
|
`INSERT INTO device_photos (device_id, file_name, file_path, mime_type, file_size, photo_type, uploaded_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
|
[deviceId, fileData.originalName, filePath, 'image/jpeg', fileData.size, photoType, req.user.email]
|
|
)
|
|
return ins[0]
|
|
})
|
|
|
|
app.get('/api/devices/:id/photos', { preHandler: requireCap('view') }, async (req) => {
|
|
const { rows } = await pool.query(
|
|
'SELECT * FROM device_photos WHERE device_id = $1 ORDER BY uploaded_at DESC',
|
|
[req.params.id]
|
|
)
|
|
return rows
|
|
})
|
|
|
|
app.delete('/api/photos/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
|
const { rows } = await pool.query('SELECT * FROM device_photos WHERE id = $1', [req.params.id])
|
|
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
|
|
await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {})
|
|
await pool.query('DELETE FROM device_photos WHERE id = $1', [req.params.id])
|
|
return { ok: true }
|
|
})
|
|
|
|
// POST /api/devices/:id/create-maintenance-asset — explicit stub. Real integration
|
|
// needs maintenance's own Settings -> API Keys page + POST /api/public/assets first
|
|
// (see lib/maintenance-client.js). Returns a clear "not implemented" response rather
|
|
// than faking a cross-app call.
|
|
app.post('/api/devices/:id/create-maintenance-asset', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
|
const { rows } = await pool.query(
|
|
`SELECT zd.*, z.name AS zone_name FROM zone_devices zd LEFT JOIN zones z ON z.id = zd.zone_id WHERE zd.id = $1`,
|
|
[req.params.id]
|
|
)
|
|
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
|
|
const result = await createMaintenanceAsset(rows[0], { name: rows[0].zone_name })
|
|
return reply.status(501).send(result)
|
|
})
|
|
}
|