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
131
frontend/src/components/KioskBulkPanel.tsx
Normal file
131
frontend/src/components/KioskBulkPanel.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { useState } from 'react'
|
||||
import { Power, Minus, Plus } from 'lucide-react'
|
||||
import { MHI_MODES, MHI_FAN_SPEEDS } from '../types'
|
||||
import type { DeviceSummary } from '../types'
|
||||
import { kioskSendTrvControl, kioskSendMhiControl, kioskSendDaikinControl } from '../api'
|
||||
|
||||
// TRV commands are staggered, not fired in parallel — these are battery/deep-sleep
|
||||
// Shelly valves going through hvac's own retry/backoff pipeline server-side
|
||||
// (setTargetWithRetry, backend/src/lib/mqtt.js); the old retired system paced bulk
|
||||
// valve commands ~10s apart for the same reason. A kiosk bulk-edit is a rarer,
|
||||
// human-triggered action rather than a scheduled mass push, so this uses a
|
||||
// shorter but still real stagger rather than firing every TRV command at once.
|
||||
const TRV_STAGGER_MS = 2500
|
||||
|
||||
interface Props {
|
||||
devices: DeviceSummary[] // the full selected set, mixed types
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
// Type-aware bulk edit — TRV and AC control logic genuinely differ (TRV is just a
|
||||
// target temp; AC has mode/setpoint/fan), so this shows a separate section per
|
||||
// device type present in the selection rather than one lowest-common-denominator
|
||||
// form. Each section's Apply only touches the matching selected devices.
|
||||
export default function KioskBulkPanel({ devices, onDone }: Props) {
|
||||
const trvIds = devices.filter(d => d.device_type === 'shelly_trv').map(d => d.id)
|
||||
const mhiIds = devices.filter(d => d.device_type === 'mhi_mqtt').map(d => d.id)
|
||||
const daikinIds = devices.filter(d => d.device_type === 'daikin').map(d => d.id)
|
||||
const acCount = mhiIds.length + daikinIds.length
|
||||
|
||||
const [trvTemp, setTrvTemp] = useState(21)
|
||||
const [acOnOff, setAcOnOff] = useState(true)
|
||||
const [acMode, setAcMode] = useState('cool')
|
||||
const [acTemp, setAcTemp] = useState(22)
|
||||
const [acFan, setAcFan] = useState('medium')
|
||||
|
||||
const [applyingTrv, setApplyingTrv] = useState(false)
|
||||
const [applyingAc, setApplyingAc] = useState(false)
|
||||
const [results, setResults] = useState<string[]>([])
|
||||
|
||||
async function applyTrv() {
|
||||
setApplyingTrv(true)
|
||||
let failed = 0
|
||||
for (const id of trvIds) {
|
||||
try {
|
||||
const res = await kioskSendTrvControl(id, { tempC: trvTemp })
|
||||
if (!res.ok) failed++
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
await new Promise(r => setTimeout(r, TRV_STAGGER_MS))
|
||||
}
|
||||
setApplyingTrv(false)
|
||||
setResults(r => [...r, `TRVs: ${trvIds.length - failed}/${trvIds.length} updated${failed ? ' — some failed' : ''}`])
|
||||
}
|
||||
|
||||
async function applyAc() {
|
||||
setApplyingAc(true)
|
||||
const patch = { onOff: acOnOff, mode: acMode, tempC: acTemp, fanSpeed: acFan }
|
||||
const outcomes = await Promise.all([
|
||||
...mhiIds.map(id => kioskSendMhiControl(id, patch).catch(() => ({ ok: false }))),
|
||||
...daikinIds.map(id => kioskSendDaikinControl(id, patch).catch(() => ({ ok: false }))),
|
||||
])
|
||||
const okCount = outcomes.filter(o => o.ok).length
|
||||
setApplyingAc(false)
|
||||
setResults(r => [...r, `AC units: ${okCount}/${acCount} updated${okCount < acCount ? ' — some failed' : ''}`])
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="field-hint" style={{ marginBottom: 14 }}>
|
||||
{devices.length} device{devices.length === 1 ? '' : 's'} selected
|
||||
{trvIds.length > 0 && acCount > 0 ? ' — TRV and AC settings apply separately, only to matching devices.' : ''}
|
||||
</p>
|
||||
|
||||
{trvIds.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="section-title" style={{ marginTop: 0 }}>TRV settings ({trvIds.length})</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button className="btn btn-sm" onClick={() => setTrvTemp(t => Math.max(5, t - 0.5))}><Minus size={12} strokeWidth={1.75} /></button>
|
||||
<span style={{ minWidth: 56, textAlign: 'center', fontWeight: 600 }}>{trvTemp.toFixed(1)}°C</span>
|
||||
<button className="btn btn-sm" onClick={() => setTrvTemp(t => Math.min(30, t + 0.5))}><Plus size={12} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={applyingTrv} onClick={applyTrv}>
|
||||
{applyingTrv ? 'Applying…' : `Apply to ${trvIds.length} TRV${trvIds.length === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{acCount > 0 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="section-title" style={{ marginTop: 0 }}>AC settings ({acCount})</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setAcOnOff(o => !o)}
|
||||
style={acOnOff ? { background: 'var(--gold)', borderColor: 'var(--gold)', color: 'var(--navy)' } : undefined}
|
||||
>
|
||||
<Power size={13} strokeWidth={1.75} /> {acOnOff ? 'On' : 'Off'}
|
||||
</button>
|
||||
<select value={acMode} onChange={e => setAcMode(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" onClick={() => setAcTemp(t => Math.max(16, t - 0.5))}><Minus size={12} strokeWidth={1.75} /></button>
|
||||
<span style={{ minWidth: 48, textAlign: 'center', fontWeight: 600 }}>{acTemp.toFixed(1)}°C</span>
|
||||
<button className="btn btn-sm" onClick={() => setAcTemp(t => Math.min(30, t + 0.5))}><Plus size={12} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
<select value={acFan} onChange={e => setAcFan(e.target.value)}>
|
||||
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary" disabled={applyingAc} onClick={applyAc}>
|
||||
{applyingAc ? 'Applying…' : `Apply to ${acCount} AC unit${acCount === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="ok-banner" style={{ marginBottom: 12 }}>
|
||||
{results.map((r, i) => <div key={i}>{r}</div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={onDone}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
frontend/src/components/KioskDeviceBlock.tsx
Normal file
57
frontend/src/components/KioskDeviceBlock.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Thermometer, Snowflake, Flame, Fan, Droplet, Heater, Check } from 'lucide-react'
|
||||
import type { DeviceSummary } from '../types'
|
||||
|
||||
const MODE_ICONS: Record<string, typeof Snowflake> = {
|
||||
cool: Snowflake,
|
||||
heat: Flame,
|
||||
fan: Fan,
|
||||
dry: Droplet,
|
||||
auto: Fan,
|
||||
}
|
||||
|
||||
function fmtTemp(t: string | number | null | undefined) {
|
||||
return t != null ? `${Number(t).toFixed(1)}°C` : '—'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
device: DeviceSummary
|
||||
zoneName: string
|
||||
selectionMode: boolean
|
||||
selected: boolean
|
||||
onTap: () => void
|
||||
}
|
||||
|
||||
// Small summary block — mode/power + setpoint at a glance, tap for the full
|
||||
// control modal (or toggle selection, in bulk-select mode). Deliberately reads
|
||||
// from the same live-status cache /api/status already returns (see Kiosk.tsx),
|
||||
// not a per-device round trip — this is what makes a scrollable grid of many of
|
||||
// these cheap to poll.
|
||||
export default function KioskDeviceBlock({ device, zoneName, selectionMode, selected, onTap }: Props) {
|
||||
const isTrv = device.device_type === 'shelly_trv'
|
||||
const ModeIcon = isTrv ? Heater : (MODE_ICONS[device.current_mode || ''] || Fan)
|
||||
const on = isTrv ? !!device.heating_active : device.power_state === 'on'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`kiosk-block${selected ? ' selected' : ''}${on ? ' on' : ''}`}
|
||||
onClick={onTap}
|
||||
role="button"
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className="kiosk-block-check">{selected && <Check size={14} strokeWidth={2.5} />}</div>
|
||||
)}
|
||||
<div className="kiosk-block-zone">
|
||||
{zoneName}
|
||||
{device.location && <span className="kiosk-block-location"> · {device.location}</span>}
|
||||
</div>
|
||||
<div className="kiosk-block-main">
|
||||
<ModeIcon size={22} strokeWidth={1.75} />
|
||||
<span className="kiosk-block-setpoint">{fmtTemp(device.current_target_temp)}</span>
|
||||
</div>
|
||||
<div className="kiosk-block-sub">
|
||||
<Thermometer size={12} strokeWidth={1.75} /> {fmtTemp(device.current_room_temp)}
|
||||
{!isTrv && device.fan_speed && <span className="kiosk-block-fan">{device.fan_speed}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/KioskModal.tsx
Normal file
55
frontend/src/components/KioskModal.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
// Auto-dismiss back to the device grid after this long with no interaction — same
|
||||
// pattern as weather's DayModal (this is also a kiosk: a modal left open by
|
||||
// whoever last tapped it shouldn't sit there indefinitely). Longer than weather's
|
||||
// 20s since dialling in a setpoint takes more thought than glancing at a forecast.
|
||||
const IDLE_MS = 30_000
|
||||
|
||||
interface KioskModalProps {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
export default function KioskModal({ title, onClose, children, wide }: KioskModalProps) {
|
||||
const [resetTick, setResetTick] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function reset() {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(onClose, IDLE_MS)
|
||||
setResetTick(t => t + 1)
|
||||
}
|
||||
reset()
|
||||
// pointerdown covers touch + mouse + stylus in one; deliberately no mousemove.
|
||||
const events = ['pointerdown', 'keydown', 'wheel'] as const
|
||||
events.forEach(e => window.addEventListener(e, reset))
|
||||
window.addEventListener('scroll', reset, true) // capture — scroll doesn't bubble
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
events.forEach(e => window.removeEventListener(e, reset))
|
||||
window.removeEventListener('scroll', reset, true)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className={`modal${wide ? ' modal-lg' : ''}`} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
<div className="kiosk-modal-timeout-track">
|
||||
<div className="kiosk-modal-timeout-bar" key={resetTick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue