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
|
|
@ -1,10 +1,11 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'
|
||||
import AuthGate, { useAuth } from './components/AuthGate'
|
||||
import IosInstallHint from './components/IosInstallHint'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Devices from './pages/Devices'
|
||||
import Settings from './pages/Settings'
|
||||
import Kiosk from './pages/Kiosk'
|
||||
import { can } from './types'
|
||||
|
||||
function Home() {
|
||||
|
|
@ -15,21 +16,35 @@ function Home() {
|
|||
return <div className="page"><p className="muted">You don't have access to any HVAC pages yet.</p></div>
|
||||
}
|
||||
|
||||
// The normal staff SPA shell — everything except /kiosk goes through the
|
||||
// central-auth AuthGate + sidebar Layout, same as before this route existed.
|
||||
function StaffShell() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<BrowserRouter basename="/hvac">
|
||||
<AuthGate>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/devices" element={<Devices />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
<Routes>
|
||||
{/* Kiosk wall-panel: its own full-screen shell, own login (a dedicated
|
||||
kiosk password, not central auth) — deliberately outside StaffShell,
|
||||
see components/Kiosk* and routes/kiosk.js on the backend. */}
|
||||
<Route path="/kiosk" element={<Kiosk />} />
|
||||
<Route element={<StaffShell />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/devices" element={<Devices />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<IosInstallHint />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -173,3 +173,61 @@ export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean
|
|||
export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> {
|
||||
return request('/settings/mqtt-status')
|
||||
}
|
||||
export function fetchKioskPasswordStatus(): Promise<{ configured: boolean }> {
|
||||
return request('/settings/kiosk-password-status')
|
||||
}
|
||||
export function setKioskPassword(password: string): Promise<{ ok: boolean }> {
|
||||
return request('/settings/kiosk-password', { method: 'POST', body: JSON.stringify({ password }) })
|
||||
}
|
||||
|
||||
// ── Kiosk (wall-panel control view) ──────────────────────────────────────
|
||||
// Deliberately separate from request() above: a 401 here means "the kiosk's
|
||||
// long-lived session lapsed (or was never logged in)" — the right response is
|
||||
// re-showing the on-screen password prompt, NOT redirecting the whole tablet
|
||||
// to the staff portal's /login like every other page in this app does.
|
||||
export class KioskSessionError extends Error {}
|
||||
|
||||
async function krequest<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
...opts,
|
||||
})
|
||||
if (res.status === 401) throw new KioskSessionError('Kiosk not authenticated')
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || `Request failed: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function kioskLogin(password: string): Promise<{ ok: boolean }> {
|
||||
return krequest('/kiosk/login', { method: 'POST', body: JSON.stringify({ password }) })
|
||||
}
|
||||
export function kioskFetchStatus(): Promise<{
|
||||
mqtt_connected: boolean
|
||||
weather_peak_c: number | null
|
||||
weather_tier: string | null
|
||||
active_ac_template: ActiveAcTemplate
|
||||
zones: ZoneStatus[]
|
||||
}> {
|
||||
return krequest('/status')
|
||||
}
|
||||
export function kioskFetchTrvStatus(deviceId: number): Promise<TrvStatus> {
|
||||
return krequest(`/devices/${deviceId}/trv-status`)
|
||||
}
|
||||
export function kioskSendTrvControl(deviceId: number, body: { tempC: number }): Promise<{ ok: boolean }> {
|
||||
return krequest(`/devices/${deviceId}/trv-control`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
export function kioskFetchMhiStatus(deviceId: number): Promise<MhiStatus> {
|
||||
return krequest(`/devices/${deviceId}/mhi-status`)
|
||||
}
|
||||
export function kioskSendMhiControl(deviceId: number, body: { onOff?: boolean; mode?: string; tempC?: number; fanSpeed?: string }): Promise<{ ok: boolean }> {
|
||||
return krequest(`/devices/${deviceId}/mhi-control`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
export function kioskFetchDaikinStatus(deviceId: number): Promise<DaikinStatus> {
|
||||
return krequest(`/devices/${deviceId}/daikin-status`)
|
||||
}
|
||||
export function kioskSendDaikinControl(deviceId: number, body: { onOff?: boolean; mode?: string; tempC?: number; fanSpeed?: string }): Promise<{ ok: boolean }> {
|
||||
return krequest(`/devices/${deviceId}/daikin-control`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -430,4 +430,60 @@ table.data tr:last-child td { border-bottom: none; }
|
|||
.sidebar::-webkit-scrollbar-track, .sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
||||
.sidebar::-webkit-scrollbar-thumb, .sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
|
||||
.sidebar::-webkit-scrollbar-thumb:hover, .sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
|
||||
|
||||
/* ── Kiosk wall-panel view ─────────────────────────────────── */
|
||||
/* Deliberately NOT modelled on weather's fixed-grid no-scroll layout — a device
|
||||
grid can outgrow one screen the way a single day's forecast never does, so
|
||||
this is a normal scrollable page, just full-screen and touch-sized. */
|
||||
.kiosk-shell { height: 100dvh; display: flex; flex-direction: column; background: var(--body-bg); }
|
||||
.kiosk-center { align-items: center; justify-content: center; }
|
||||
.kiosk-login-card {
|
||||
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md); padding: 28px 26px; width: 100%; max-width: 340px; text-align: center;
|
||||
}
|
||||
.kiosk-login-card input {
|
||||
border: 1px solid var(--card-border); border-radius: 8px; padding: 10px 12px; font-size: 16px;
|
||||
}
|
||||
|
||||
.kiosk-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px; background: var(--navy); color: var(--text); flex-shrink: 0;
|
||||
}
|
||||
.kiosk-header-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; color: var(--gold); }
|
||||
|
||||
.kiosk-error { margin: 12px 16px 0; }
|
||||
|
||||
.kiosk-grid-wrap { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.kiosk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
|
||||
|
||||
.kiosk-block {
|
||||
position: relative; cursor: pointer; background: var(--card-bg);
|
||||
border: 1px solid var(--card-border); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm); padding: 12px; min-height: 96px;
|
||||
display: flex; flex-direction: column; gap: 6px; user-select: none;
|
||||
}
|
||||
.kiosk-block.on { border-color: var(--app-primary); }
|
||||
.kiosk-block.selected { outline: 2px solid var(--gold); outline-offset: -2px; }
|
||||
.kiosk-block-check {
|
||||
position: absolute; top: 8px; right: 8px; width: 20px; height: 20px; border-radius: 5px;
|
||||
border: 1.5px solid var(--card-border); display: flex; align-items: center; justify-content: center;
|
||||
color: var(--navy); background: var(--card-bg);
|
||||
}
|
||||
.kiosk-block.selected .kiosk-block-check { background: var(--gold); border-color: var(--gold); color: var(--navy); }
|
||||
.kiosk-block-zone { font-size: 12px; font-weight: 700; color: var(--text-dark); padding-right: 22px; }
|
||||
.kiosk-block-location { font-weight: 500; color: var(--text-mid); }
|
||||
.kiosk-block-main { display: flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 700; color: var(--text-dark); }
|
||||
.kiosk-block.on .kiosk-block-main { color: var(--app-primary); }
|
||||
.kiosk-block-sub { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--text-mid); }
|
||||
.kiosk-block-fan { margin-left: auto; text-transform: capitalize; }
|
||||
|
||||
.kiosk-bulk-bar {
|
||||
position: sticky; bottom: 0; display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 18px; background: var(--navy); color: var(--text); flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 30s duration must match KioskModal.tsx's IDLE_MS */
|
||||
.kiosk-modal-timeout-track { height: 3px; background: var(--card-border); border-radius: 2px; margin-top: 18px; overflow: hidden; }
|
||||
.kiosk-modal-timeout-bar { height: 100%; width: 100%; background: var(--gold); transform-origin: left; animation: kiosk-timeout-shrink 30s linear forwards; }
|
||||
@keyframes kiosk-timeout-shrink { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
.sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Wifi, WifiOff } from 'lucide-react'
|
||||
import { Wifi, WifiOff, MonitorCog } from 'lucide-react'
|
||||
import {
|
||||
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
|
||||
fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone,
|
||||
fetchKioskPasswordStatus, setKioskPassword,
|
||||
} from '../api'
|
||||
import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate, HvacWeatherTier } from '../types'
|
||||
import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } from '../types'
|
||||
|
|
@ -41,6 +42,12 @@ export default function Settings() {
|
|||
const [publicAreaName, setPublicAreaName] = useState('')
|
||||
const [creatingZone, setCreatingZone] = useState(false)
|
||||
|
||||
const [kioskConfigured, setKioskConfigured] = useState<boolean | null>(null)
|
||||
const [kioskPassword, setKioskPasswordInput] = useState('')
|
||||
const [kioskSaving, setKioskSaving] = useState(false)
|
||||
const [kioskMsg, setKioskMsg] = useState('')
|
||||
const [kioskError, setKioskError] = useState('')
|
||||
|
||||
function loadSites() {
|
||||
fetchNewbookSites()
|
||||
.then(res => setSites(res.sites))
|
||||
|
|
@ -64,6 +71,7 @@ export default function Settings() {
|
|||
setDefaults({ ...DEFAULT_HVAC_TEMPLATE, ...cfg.hvac_defaults, ac_occupied_tiers: cfg.hvac_defaults?.ac_occupied_tiers ?? DEFAULT_HVAC_TEMPLATE.ac_occupied_tiers })
|
||||
}).catch(() => {})
|
||||
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
|
||||
fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {})
|
||||
fetchStatus()
|
||||
.then(s => { setWeatherPeakC(s.weather_peak_c); setWeatherTier(s.weather_tier) })
|
||||
.catch(() => {})
|
||||
|
|
@ -165,6 +173,20 @@ export default function Settings() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSaveKioskPassword() {
|
||||
setKioskSaving(true); setKioskError(''); setKioskMsg('')
|
||||
try {
|
||||
await setKioskPassword(kioskPassword)
|
||||
setKioskPasswordInput('')
|
||||
setKioskConfigured(true)
|
||||
setKioskMsg('Kiosk password saved — enter it once on each kiosk tablet at /hvac/kiosk.')
|
||||
} catch (e) {
|
||||
setKioskError(e instanceof Error ? e.message : 'Save failed')
|
||||
} finally {
|
||||
setKioskSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePublicArea(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!publicAreaName.trim()) return
|
||||
|
|
@ -442,6 +464,36 @@ export default function Settings() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-title">Kiosk Wall Panel</div>
|
||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||
A dedicated login for the full-screen wall-panel control view at <code>/hvac/kiosk</code> — separate
|
||||
from staff logins, meant to be entered once per tablet and left signed in indefinitely (see{' '}
|
||||
<MonitorCog size={12} strokeWidth={1.75} style={{ verticalAlign: '-1px' }} /> the kiosk itself). Scoped to
|
||||
view + control only — it can never reach this Settings page or device assignment.
|
||||
</p>
|
||||
{kioskError && <div className="error-banner">{kioskError}</div>}
|
||||
{kioskMsg && <div className="ok-banner">{kioskMsg}</div>}
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<MonitorCog size={16} strokeWidth={1.75} color={kioskConfigured ? 'var(--health-healthy)' : 'var(--text-mid)'} />
|
||||
<span>
|
||||
{kioskConfigured == null ? 'Checking…' : kioskConfigured ? 'A kiosk password is configured.' : 'No kiosk password set yet — the kiosk view cannot be logged into until one is set below.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="field-row" style={{ marginBottom: 12 }}>
|
||||
<div className="field">
|
||||
<label>{kioskConfigured ? 'New kiosk password' : 'Kiosk password'}</label>
|
||||
<input
|
||||
type="password" value={kioskPassword} onChange={e => setKioskPasswordInput(e.target.value)}
|
||||
placeholder="At least 6 characters" autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<button className="btn btn-primary" disabled={kioskSaving || kioskPassword.length < 6} onClick={handleSaveKioskPassword}>
|
||||
{kioskSaving ? 'Saving…' : kioskConfigured ? 'Change kiosk password' : 'Set kiosk password'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="section-title">Maintenance Integration</div>
|
||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||
Not yet available — maintenance needs its own Settings → API Keys page first.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
|
|||
// value only, ready for its driver (currently just 'midea').
|
||||
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant', 'daikin']
|
||||
|
||||
// Device types the kiosk wall-panel actually shows/controls — deliberately
|
||||
// narrower than IMPLEMENTED_DEVICE_TYPES (excludes home_assistant, which has no
|
||||
// kiosk-friendly control shape yet). See project_hvac_kiosk memory's "v1 device
|
||||
// scope" decision — widen this if/when mitsubishi_cn105/midea_mqtt/hitachi_ir land.
|
||||
export const KIOSK_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'daikin']
|
||||
export const KIOSK_AC_DEVICE_TYPES: DeviceType[] = ['mhi_mqtt', 'daikin']
|
||||
|
||||
// TRV temps/offsets and aircon template fields are all nullable on a zone — NULL means
|
||||
// "inherit the matching field from hvac_defaults" (see HvacDefaults below); a non-null
|
||||
// value is that room's explicit override. Set via ZoneDetailModal's per-field toggles.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue