import { useEffect, useState } from 'react' import { Wifi, WifiOff } from 'lucide-react' import { fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook, fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone, } from '../api' import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate, HvacWeatherTier } from '../types' import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } from '../types' export default function Settings() { const [sites, setSites] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [msg, setMsg] = useState('') const [testing, setTesting] = useState(false) const [syncing, setSyncing] = useState(false) const [config, setConfig] = useState(null) const [automationEnabled, setAutomationEnabled] = useState(true) const [automationSaving, setAutomationSaving] = useState(false) const [pollMinutes, setPollMinutes] = useState(10) const [defaultArrival, setDefaultArrival] = useState('15:00:00') const [defaultDeparture, setDefaultDeparture] = useState('10:00:00') const [maintUrl, setMaintUrl] = useState('') const [maintKey, setMaintKey] = useState('') const [daikinCidr, setDaikinCidr] = useState('') const [configSaving, setConfigSaving] = useState(false) // Default occupied/vacant templates — every room inherits these unless it sets its // own override (ZoneDetailModal's per-field "Override" toggles). const [defaults, setDefaults] = useState(DEFAULT_HVAC_TEMPLATE) const [defaultsSaving, setDefaultsSaving] = useState(false) const [mqttConnected, setMqttConnected] = useState(null) const [mqttNote, setMqttNote] = useState('') const [weatherPeakC, setWeatherPeakC] = useState(null) const [weatherTier, setWeatherTier] = useState(null) const [weatherLoaded, setWeatherLoaded] = useState(false) const [publicAreaName, setPublicAreaName] = useState('') const [creatingZone, setCreatingZone] = useState(false) function loadSites() { fetchNewbookSites() .then(res => setSites(res.sites)) .catch(e => setError(e instanceof Error ? e.message : 'Failed to load NewBook sites')) .finally(() => setLoading(false)) } useEffect(() => { loadSites() fetchConfig().then(cfg => { setConfig(cfg) setAutomationEnabled(cfg.automation_enabled ?? true) setPollMinutes(cfg.poll_interval_minutes ?? 10) setDefaultArrival(cfg.default_arrival_time ?? '15:00:00') setDefaultDeparture(cfg.default_departure_time ?? '10:00:00') setMaintUrl(cfg.maintenance_url ?? '') setMaintKey(cfg.maintenance_api_key ?? '') setDaikinCidr(cfg.daikin_subnet_cidr ?? '') // Merge onto the shipped default rather than replacing outright — an hvac_defaults // row saved before weather compensation existed won't have ac_occupied_tiers yet. 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(() => {}) fetchStatus() .then(s => { setWeatherPeakC(s.weather_peak_c); setWeatherTier(s.weather_tier) }) .catch(() => {}) .finally(() => setWeatherLoaded(true)) }, []) function toggleExcluded(siteId: string) { setSites(sites.map(s => s.site_id === siteId ? { ...s, excluded: !s.excluded } : s)) } async function saveSites() { setError(''); setMsg('') try { await saveExcludedSites(sites.filter(s => s.excluded).map(s => s.site_id)) setMsg('Saved'); setTimeout(() => setMsg(''), 2500) } catch (e) { setError(e instanceof Error ? e.message : 'Save failed') } } async function handleTest() { setTesting(true); setError(''); setMsg('') try { const res = await testNewbookConnection() setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`) } catch (e) { setError(e instanceof Error ? e.message : 'Test failed') } finally { setTesting(false) } } async function handleSync() { setSyncing(true); setError(''); setMsg('') try { const res = await syncZonesFromNewbook() setMsg(`Synced: ${res.created} created, ${res.updated} updated (${res.total} active sites)`) } catch (e) { setError(e instanceof Error ? e.message : 'Sync failed') } finally { setSyncing(false) } } async function handleAutomationToggle() { const next = !automationEnabled setAutomationSaving(true); setError(''); setMsg('') try { await updateConfig('automation_enabled', next) setAutomationEnabled(next) setMsg(next ? 'Automation resumed' : 'Automation paused — device setpoints will no longer change until resumed') setTimeout(() => setMsg(''), 4000) } catch (e) { setError(e instanceof Error ? e.message : 'Failed to update automation state') } finally { setAutomationSaving(false) } } async function saveGeneralConfig() { setConfigSaving(true); setError(''); setMsg('') try { await Promise.all([ updateConfig('poll_interval_minutes', pollMinutes), updateConfig('default_arrival_time', defaultArrival), updateConfig('default_departure_time', defaultDeparture), updateConfig('maintenance_url', maintUrl), updateConfig('maintenance_api_key', maintKey), updateConfig('daikin_subnet_cidr', daikinCidr), ]) setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500) } catch (e) { setError(e instanceof Error ? e.message : 'Save failed') } finally { setConfigSaving(false) } } function updateAcDefault(which: 'ac_occupied' | 'ac_vacant', field: keyof HvacAcTemplate, value: string | number) { setDefaults(d => ({ ...d, [which]: { ...d[which], [field]: value } })) } function updateTier(index: number, field: keyof HvacWeatherTier, value: string | number) { setDefaults(d => ({ ...d, ac_occupied_tiers: d.ac_occupied_tiers.map((t, i) => i === index ? { ...t, [field]: value } : t), })) } async function saveDefaults() { setDefaultsSaving(true); setError(''); setMsg('') try { await updateConfig('hvac_defaults', defaults) setMsg('Default templates saved'); setTimeout(() => setMsg(''), 2500) } catch (e) { setError(e instanceof Error ? e.message : 'Save failed') } finally { setDefaultsSaving(false) } } async function handleCreatePublicArea(e: React.FormEvent) { e.preventDefault() if (!publicAreaName.trim()) return setCreatingZone(true); setError(''); setMsg('') try { await createZone({ name: publicAreaName.trim(), zone_type: 'public_area' }) setPublicAreaName('') setMsg('Public area zone created — assign devices to it on the Devices page.') } catch (e) { setError(e instanceof Error ? e.message : 'Create failed') } finally { setCreatingZone(false) } } if (loading) return

Loading…

return (

Settings

{error &&
{error}
} {msg &&
{msg}
}
Automation
{automationEnabled ? 'Automation is ON' : 'Automation is PAUSED'}
Master switch for every zone — overrides each zone's own auto mode. While paused, NewBook polling, room-state tracking and activity logging all keep running as normal — only setpoint changes to TRVs/aircon devices are skipped.
MQTT Broker
{mqttConnected ? : } {mqttNote || 'Checking…'}
NewBook Rooms

Untick rooms that shouldn't get NewBook-driven heating scheduling (e.g. owner-occupied or out-of-service rooms).

{sites.map(s => ( ))} {sites.length === 0 &&

No sites returned — check NewBook connection.

}
Add a Public Area
setPublicAreaName(e.target.value)} style={{ flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, padding: '8px 10px' }} />
Scheduling
setPollMinutes(parseInt(e.target.value) || 10)} />
setDefaultArrival(e.target.value)} placeholder="15:00:00" />
setDefaultDeparture(e.target.value)} placeholder="10:00:00" />
Default Templates

Fallback occupied/vacant state for every room's TRVs and aircon. Any room can override individual values on its own card (Dashboard → click the room → Schedule section).

setDefaults(d => ({ ...d, arrival_prep_offset_min: parseInt(e.target.value) || 0 }))} />
setDefaults(d => ({ ...d, departure_prep_offset_min: parseInt(e.target.value) || 0 }))} />
Default Aircon
{(['ac_occupied', 'ac_vacant'] as const).map(which => ( ))} {(['ac_occupied', 'ac_vacant'] as const).map(which => ( ))} {(['ac_occupied', 'ac_vacant'] as const).map(which => ( ))} {(['ac_occupied', 'ac_vacant'] as const).map(which => ( ))}
OccupiedVacant
On/Off
Mode
Setpoint (°C) updateAcDefault(which, 'setpoint', parseFloat(e.target.value) || 0)} />
Fan speed
Weather Compensation

Overrides the Occupied aircon template above on a hot day. At check-in/arrival, the scheduler checks today's forecast peak (from Settings → Integrations → Hotel Location / Met Office DataHub, falls back to Open-Meteo with no key needed) and uses the highest tier below whose threshold it clears — below every threshold, the Default Aircon "Occupied" row above still applies unchanged. Only re-checked at the moment a room becomes occupied, never mid-stay, so it can't override a guest's own adjustment later in their booking. {weatherPeakC != null && ( <> Today's forecast peak: {weatherPeakC}°C{weatherTier ? <> — currently in the {weatherTier} tier : ' — below every tier'}. )}

{weatherLoaded && weatherPeakC == null && (

No forecast available right now — check Hotel Location and Met Office DataHub under Settings → Integrations are saved correctly (both need a Test to pass), or check hvac-backend's logs for a "[weather]" line explaining why.

)}
{defaults.ac_occupied_tiers.map((tier, i) => ( ))}
TierIf peak above (°C)On/OffModeSetpoint (°C)Fan speed
{tier.name} updateTier(i, 'peak_above_c', parseFloat(e.target.value) || 0)} /> updateTier(i, 'setpoint', parseFloat(e.target.value) || 0)} />
Default TRVs
setDefaults(d => ({ ...d, trv_occupied_temp: parseFloat(e.target.value) || 0 }))} />
setDefaults(d => ({ ...d, trv_vacant_temp: parseFloat(e.target.value) || 0 }))} />
Daikin Aircon

Daikin units are WiFi/DHCP, so their IP changes over time. Instead of a fixed IP, hvac periodically sweeps this LAN range over HTTP and matches units by their MAC address — set it once and it self-heals if a unit's IP changes. Run "Discover Daikin Split" on the Devices page after saving to find units for the first time.

setDaikinCidr(e.target.value)} placeholder="10.4.0.0/22" />
Maintenance Integration

Not yet available — maintenance needs its own Settings → API Keys page first. Once that lands, generate a key there and paste it here to enable "Create asset in Maintenance" on the Devices page.

setMaintUrl(e.target.value)} placeholder="https://hotel.example.com/maintenance" />
setMaintKey(e.target.value)} placeholder="paste key" />
) }