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>
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
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>
|
|
)
|
|
}
|