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 2/3 // types (mhi_modbus, midea, daikin) are enum values only — no driver yet. const DRIVERS = { shelly_trv: trvDriver, home_assistant: haDriver, } const NOT_YET_IMPLEMENTED = ['mhi_modbus', 'midea', 'daikin'] 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 (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.`, }) } 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) }) }