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:
jtricerolph 2026-07-27 14:43:38 +00:00
parent a9b5703c57
commit e5c40bbb8c
14 changed files with 935 additions and 20 deletions

View file

@ -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')

View 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>
)
}

View 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>
)
}

View file

@ -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,15 +89,18 @@ 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>
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.location || d.discovered_name || d.external_ref}</div>
<div className="muted" style={{ fontSize: 12 }}>
{d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'}
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
<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 }}>
{d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'}
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
</div>
</div>
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
</div>
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
{d.device_type === 'mhi_modbus' && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
</div>
))}

View file

@ -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 &amp; Discovered Devices</div>
{devices.length === 0 ? (
<div className="empty-state">No devices discovered yet run a discover scan above.</div>

View file

@ -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