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
238
frontend/src/components/GatewaysPanel.tsx
Normal file
238
frontend/src/components/GatewaysPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
frontend/src/components/MhiControlPanel.tsx
Normal file
102
frontend/src/components/MhiControlPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue