Add kiosk wall-panel control view
Full-screen /kiosk route, separate from the staff SPA: dedicated long-lived kiosk login (own password, hashed, mints its own hnf_session cookie locally via CENTRAL_AUTH_SECRET -- no central auth changes needed), scrollable grid of small mode/setpoint blocks per live-controllable device (shelly_trv, mhi_mqtt, daikin), tap-to-edit modal reusing the existing per-type control panels, and a type-aware bulk multiselect (separate TRV/AC sections, only applied to matching selected devices, TRV commands staggered). - backend/src/routes/kiosk.js: public POST /api/kiosk/login - backend/src/routes/settings.js: admin set/check kiosk password (bcrypt) - backend/src/routes/config.js: exclude/protect kiosk_password_hash - frontend/src/pages/Kiosk.tsx + components/Kiosk*: the kiosk UI - reuses GET /api/status for the combined device list -- no new status endpoint needed since the kiosk token now also carries the 'view' cap Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f0b4ac3c41
commit
2db7d5f4d0
16 changed files with 757 additions and 14 deletions
201
frontend/src/pages/Kiosk.tsx
Normal file
201
frontend/src/pages/Kiosk.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Thermometer, CheckSquare, X } from 'lucide-react'
|
||||
import { kioskLogin, kioskFetchStatus, KioskSessionError } from '../api'
|
||||
import type { DeviceSummary, ZoneStatus } from '../types'
|
||||
import { KIOSK_DEVICE_TYPES } from '../types'
|
||||
import KioskDeviceBlock from '../components/KioskDeviceBlock'
|
||||
import KioskModal from '../components/KioskModal'
|
||||
import KioskBulkPanel from '../components/KioskBulkPanel'
|
||||
import TrvControlPanel from '../components/TrvControlPanel'
|
||||
import MhiControlPanel from '../components/MhiControlPanel'
|
||||
import DaikinControlPanel from '../components/DaikinControlPanel'
|
||||
|
||||
// Control surface, not a passive display — far shorter than weather's 5-minute
|
||||
// precedent, but not so aggressive it hammers the backend given the underlying
|
||||
// data is already push/poll-cached (MQTT for TRV/MHI, a 60s poller for Daikin).
|
||||
const POLL_MS = 12_000
|
||||
|
||||
interface FlatDevice { device: DeviceSummary; zoneName: string }
|
||||
|
||||
function flattenDevices(zones: ZoneStatus[]): FlatDevice[] {
|
||||
const out: FlatDevice[] = []
|
||||
for (const z of zones) {
|
||||
for (const d of z.devices) {
|
||||
if (KIOSK_DEVICE_TYPES.includes(d.device_type)) out.push({ device: d, zoneName: z.name })
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.zoneName.localeCompare(b.zoneName) || (a.device.location || '').localeCompare(b.device.location || ''))
|
||||
}
|
||||
|
||||
// The wall-panel kiosk view — a dedicated route/login, deliberately outside the
|
||||
// normal staff AuthGate/Layout (see App.tsx). One master panel: every live-
|
||||
// controllable device (shelly_trv / mhi_mqtt / daikin) in one scrollable grid of
|
||||
// small blocks; tap a block for the full control modal, or toggle Select for
|
||||
// type-aware bulk editing. See project_hvac_kiosk memory for the design decisions
|
||||
// behind this (access model, layout, bulk-edit mechanism).
|
||||
export default function Kiosk() {
|
||||
const [authState, setAuthState] = useState<'checking' | 'login' | 'authed'>('checking')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loginError, setLoginError] = useState('')
|
||||
const [loginLoading, setLoginLoading] = useState(false)
|
||||
|
||||
const [flatDevices, setFlatDevices] = useState<FlatDevice[]>([])
|
||||
const [loadError, setLoadError] = useState('')
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [editingDevice, setEditingDevice] = useState<DeviceSummary | null>(null)
|
||||
const [bulkOpen, setBulkOpen] = useState(false)
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
kioskFetchStatus()
|
||||
.then(s => {
|
||||
setFlatDevices(flattenDevices(s.zones))
|
||||
setLoadError('')
|
||||
setAuthState('authed')
|
||||
})
|
||||
.catch(e => {
|
||||
if (e instanceof KioskSessionError) { setAuthState('login'); return }
|
||||
setLoadError(e instanceof Error ? e.message : 'Failed to load device status')
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (authState !== 'authed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
return
|
||||
}
|
||||
pollRef.current = setInterval(load, POLL_MS)
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}, [authState, load])
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoginLoading(true); setLoginError('')
|
||||
try {
|
||||
await kioskLogin(password)
|
||||
setPassword('')
|
||||
load()
|
||||
} catch (e) {
|
||||
setLoginError(e instanceof Error ? e.message : 'Login failed')
|
||||
} finally {
|
||||
setLoginLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectionMode() {
|
||||
setSelectionMode(m => !m)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
function tapDevice(d: DeviceSummary) {
|
||||
if (selectionMode) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(d.id)) next.delete(d.id); else next.add(d.id)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setEditingDevice(d)
|
||||
}
|
||||
}
|
||||
|
||||
function closeBulk() {
|
||||
setBulkOpen(false)
|
||||
setSelectionMode(false)
|
||||
setSelectedIds(new Set())
|
||||
load()
|
||||
}
|
||||
|
||||
const selectedDevices = flatDevices.filter(fd => selectedIds.has(fd.device.id)).map(fd => fd.device)
|
||||
const editingZoneName = editingDevice ? flatDevices.find(fd => fd.device.id === editingDevice.id)?.zoneName : ''
|
||||
|
||||
if (authState === 'checking') {
|
||||
return <div className="kiosk-shell kiosk-center"><p className="muted">Loading…</p></div>
|
||||
}
|
||||
|
||||
if (authState === 'login') {
|
||||
return (
|
||||
<div className="kiosk-shell kiosk-center">
|
||||
<div className="kiosk-login-card">
|
||||
<Thermometer size={28} strokeWidth={1.5} style={{ color: 'var(--gold)', marginBottom: 8 }} />
|
||||
<h1 style={{ fontSize: '1.3rem', margin: '0 0 1.25rem' }}>HVAC Kiosk</h1>
|
||||
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<input
|
||||
type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Kiosk password" autoFocus required autoComplete="off"
|
||||
/>
|
||||
{loginError && <div className="error-banner">{loginError}</div>}
|
||||
<button className="btn btn-primary" type="submit" disabled={loginLoading}>
|
||||
{loginLoading ? 'Signing in…' : 'Unlock'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kiosk-shell">
|
||||
<header className="kiosk-header">
|
||||
<span className="kiosk-header-title">
|
||||
<Thermometer size={18} strokeWidth={1.75} /> HVAC Kiosk
|
||||
</span>
|
||||
<button className={`btn btn-sm${selectionMode ? ' btn-primary' : ''}`} onClick={toggleSelectionMode}>
|
||||
<CheckSquare size={14} strokeWidth={1.75} /> {selectionMode ? 'Cancel select' : 'Select'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{loadError && <div className="error-banner kiosk-error">{loadError}</div>}
|
||||
|
||||
<div className="kiosk-grid-wrap">
|
||||
<div className="kiosk-grid">
|
||||
{flatDevices.map(({ device, zoneName }) => (
|
||||
<KioskDeviceBlock
|
||||
key={device.id}
|
||||
device={device}
|
||||
zoneName={zoneName}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(device.id)}
|
||||
onTap={() => tapDevice(device)}
|
||||
/>
|
||||
))}
|
||||
{flatDevices.length === 0 && !loadError && (
|
||||
<p className="muted">No live-controllable devices assigned to a zone yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectionMode && selectedIds.size > 0 && (
|
||||
<div className="kiosk-bulk-bar">
|
||||
<span>{selectedIds.size} selected</span>
|
||||
<button className="btn btn-primary" onClick={() => setBulkOpen(true)}>Edit selected</button>
|
||||
<button className="btn btn-sm" onClick={() => setSelectedIds(new Set())} aria-label="Clear selection">
|
||||
<X size={13} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingDevice && (
|
||||
<KioskModal
|
||||
title={editingDevice.location ? `${editingZoneName} — ${editingDevice.location}` : editingZoneName || 'Device'}
|
||||
onClose={() => { setEditingDevice(null); load() }}
|
||||
>
|
||||
{editingDevice.device_type === 'shelly_trv' && <TrvControlPanel deviceId={editingDevice.id} canControl={true} />}
|
||||
{editingDevice.device_type === 'mhi_mqtt' && <MhiControlPanel deviceId={editingDevice.id} canControl={true} />}
|
||||
{editingDevice.device_type === 'daikin' && <DaikinControlPanel deviceId={editingDevice.id} canControl={true} />}
|
||||
</KioskModal>
|
||||
)}
|
||||
|
||||
{bulkOpen && (
|
||||
<KioskModal title={`Edit ${selectedDevices.length} selected`} wide onClose={closeBulk}>
|
||||
<KioskBulkPanel devices={selectedDevices} onDone={closeBulk} />
|
||||
</KioskModal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue