Add configurable water-softener diagram to Dashboard
Per-asset diagram_config (JSONB) maps live MQTT field_keys to brine/resin tank roles, editable on the Assets page with dropdowns populated from that asset's actual telemetry. Dashboard renders brine + 1-2 resin tanks with color-coded fill levels, an "in service" tank indicator, and an animated flow/regen indicator when regeneration is active.
This commit is contained in:
parent
eca07750d2
commit
4f048a9629
9 changed files with 4779 additions and 11 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import type {
|
||||
PlantAsset, AssetStatus, AssetPhoto, AlertRule, PlantAlert, AppSetting, PhotoType,
|
||||
PlantAsset, AssetStatus, AssetPhoto, AlertRule, PlantAlert, AppSetting, PhotoType, TelemetryField,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/plant/api'
|
||||
|
|
@ -31,6 +31,9 @@ export function createAsset(body: Partial<PlantAsset>): Promise<PlantAsset> {
|
|||
export function updateAsset(id: number, body: Partial<PlantAsset>): Promise<PlantAsset> {
|
||||
return request(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
}
|
||||
export function fetchAssetLatest(id: number): Promise<TelemetryField[]> {
|
||||
return request(`/assets/${id}/latest`)
|
||||
}
|
||||
|
||||
// Asset photos — multipart, so no JSON content-type header
|
||||
export async function uploadAssetPhoto(assetId: number, file: File, photoType: PhotoType): Promise<AssetPhoto> {
|
||||
|
|
|
|||
132
frontend/src/components/WaterSoftenerDiagram.tsx
Normal file
132
frontend/src/components/WaterSoftenerDiagram.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { RefreshCw, ChevronsRight } from 'lucide-react'
|
||||
import type { AssetStatus, TelemetryField } from '../types'
|
||||
|
||||
function fieldByKey(latest: TelemetryField[], key: string | null): TelemetryField | undefined {
|
||||
if (!key) return undefined
|
||||
return latest.find(f => f.field_key === key)
|
||||
}
|
||||
|
||||
function numericValue(f?: TelemetryField): number | null {
|
||||
if (!f || f.value_numeric === null || f.value_numeric === undefined) return null
|
||||
return Number(f.value_numeric)
|
||||
}
|
||||
|
||||
// Handles whatever shape the underlying MQTT payload actually took —
|
||||
// ESPHome binary sensors publish "ON"/"OFF" by default but can be configured
|
||||
// for "true"/"false"; numeric 1/0 is covered by numeric_value === 1 too.
|
||||
function isTruthy(f?: TelemetryField): boolean {
|
||||
if (!f) return false
|
||||
if (f.value_numeric !== null && f.value_numeric !== undefined) return Number(f.value_numeric) === 1
|
||||
const t = (f.value_text || '').trim().toLowerCase()
|
||||
return t === 'true' || t === 'on' || t === '1' || t === 'yes'
|
||||
}
|
||||
|
||||
function levelClass(pct: number | null): string {
|
||||
if (pct === null) return 'softener-fill-unknown'
|
||||
if (pct < 15) return 'softener-fill-critical'
|
||||
if (pct < 35) return 'softener-fill-warning'
|
||||
return 'softener-fill-ok'
|
||||
}
|
||||
|
||||
type Variant = 'active' | 'standby' | 'single'
|
||||
|
||||
function tankVariant(active: boolean, regenerating: boolean, single: boolean): Variant {
|
||||
if (regenerating) return 'active'
|
||||
if (single) return 'single'
|
||||
return active ? 'active' : 'standby'
|
||||
}
|
||||
|
||||
function Tank({ label, pct, valueLabel, variant, regenerating }: {
|
||||
label: string
|
||||
pct: number | null
|
||||
valueLabel: string
|
||||
variant: Variant
|
||||
regenerating: boolean
|
||||
}) {
|
||||
const clamped = pct === null ? 0 : Math.max(0, Math.min(100, pct))
|
||||
return (
|
||||
<div className={`softener-tank softener-tank-${variant}`}>
|
||||
{variant === 'active' && (
|
||||
<div className={`softener-badge ${regenerating ? 'softener-badge-regen' : ''}`}>
|
||||
{regenerating && <RefreshCw size={11} strokeWidth={2} className="spin" />}
|
||||
{regenerating ? 'Regenerating' : 'In service'}
|
||||
</div>
|
||||
)}
|
||||
<div className="softener-tank-shell">
|
||||
<div className={`softener-tank-fill ${levelClass(pct)}`} style={{ height: `${clamped}%` }} />
|
||||
<div className="softener-tank-pct">{pct === null ? '—' : `${Math.round(pct)}%`}</div>
|
||||
</div>
|
||||
<div className="softener-tank-label">{label}</div>
|
||||
<div className="softener-tank-value">{valueLabel}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WaterSoftenerDiagram({ asset }: { asset: AssetStatus }) {
|
||||
const cfg = asset.diagram_config
|
||||
if (!cfg || !cfg.enabled) {
|
||||
return (
|
||||
<div className="softener-diagram-empty">
|
||||
Diagram not set up yet — configure field mappings on the Assets page.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const single = cfg.tank_count === 1
|
||||
const tankAField = fieldByKey(asset.latest, cfg.tank_a_field)
|
||||
const tankBField = fieldByKey(asset.latest, cfg.tank_b_field)
|
||||
const brineField = fieldByKey(asset.latest, cfg.brine_level_field)
|
||||
const regenField = fieldByKey(asset.latest, cfg.regen_active_field)
|
||||
const inServiceField = fieldByKey(asset.latest, cfg.tank_in_service_field)
|
||||
|
||||
const regenActive = isTruthy(regenField)
|
||||
const rawTruthy = isTruthy(inServiceField)
|
||||
const bInService = !single && (cfg.tank_b_when_truthy ? rawTruthy : !rawTruthy)
|
||||
|
||||
const aValL = numericValue(tankAField)
|
||||
const aPct = aValL !== null && cfg.tank_a_max_l ? (aValL / cfg.tank_a_max_l) * 100 : null
|
||||
const bValL = numericValue(tankBField)
|
||||
const bPct = bValL !== null && cfg.tank_b_max_l ? (bValL / cfg.tank_b_max_l) * 100 : null
|
||||
const brinePct = numericValue(brineField)
|
||||
|
||||
return (
|
||||
<div className="softener-diagram">
|
||||
{regenActive && (
|
||||
<div className="softener-regen-banner">
|
||||
<RefreshCw size={13} strokeWidth={2} className="spin" />
|
||||
Regeneration in progress
|
||||
</div>
|
||||
)}
|
||||
<div className="softener-diagram-row">
|
||||
<Tank
|
||||
label="Brine tank"
|
||||
pct={brinePct}
|
||||
valueLabel={brinePct === null ? 'No reading' : `${Math.round(brinePct)}% salt/brine`}
|
||||
variant="single"
|
||||
regenerating={false}
|
||||
/>
|
||||
<div className={`softener-flow ${regenActive ? 'softener-flow-active' : ''}`} aria-hidden="true">
|
||||
<ChevronsRight size={16} strokeWidth={2} />
|
||||
<ChevronsRight size={16} strokeWidth={2} />
|
||||
<ChevronsRight size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<Tank
|
||||
label={single ? 'Resin tank' : 'Tank A'}
|
||||
pct={aPct}
|
||||
valueLabel={aValL === null ? 'No reading' : cfg.tank_a_max_l ? `${Math.round(aValL)} / ${cfg.tank_a_max_l} L` : `${Math.round(aValL)} L`}
|
||||
variant={tankVariant(!bInService, regenActive && !bInService, single)}
|
||||
regenerating={regenActive && !bInService}
|
||||
/>
|
||||
{!single && (
|
||||
<Tank
|
||||
label="Tank B"
|
||||
pct={bPct}
|
||||
valueLabel={bValL === null ? 'No reading' : cfg.tank_b_max_l ? `${Math.round(bValL)} / ${cfg.tank_b_max_l} L` : `${Math.round(bValL)} L`}
|
||||
variant={tankVariant(bInService, regenActive && bInService, false)}
|
||||
regenerating={regenActive && bInService}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -209,6 +209,111 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
|||
.asset-field-key { color: var(--text-mid); }
|
||||
.asset-field-value { font-weight: 600; color: var(--text-dark); }
|
||||
|
||||
/* ── Water softener diagram ────────────────────────────────── */
|
||||
.softener-diagram-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-mid);
|
||||
background: var(--body-bg);
|
||||
border: 1px dashed var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.softener-diagram { margin-bottom: 12px; }
|
||||
.softener-regen-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--gold);
|
||||
background: rgba(201,168,76,.12);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
.softener-diagram-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.softener-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--card-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.softener-flow svg { margin-left: -6px; }
|
||||
.softener-flow-active svg { color: var(--app-primary); animation: softener-flow-pulse 1.1s ease-in-out infinite; }
|
||||
.softener-flow-active svg:nth-child(2) { animation-delay: .15s; }
|
||||
.softener-flow-active svg:nth-child(3) { animation-delay: .3s; }
|
||||
@keyframes softener-flow-pulse {
|
||||
0%, 100% { opacity: .25; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.softener-tank {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 76px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.softener-tank-standby { opacity: .5; }
|
||||
.softener-tank-active .softener-tank-shell { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); }
|
||||
.softener-badge {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .02em;
|
||||
border-radius: 20px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.softener-badge-regen { background: var(--app-primary); color: #fff; }
|
||||
.softener-tank-shell {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 90px;
|
||||
border: 2px solid var(--card-border);
|
||||
border-radius: 10px 10px 6px 6px;
|
||||
background: var(--body-bg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.softener-tank-fill {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
transition: height .5s ease;
|
||||
}
|
||||
.softener-fill-ok { background: var(--sev-ok); }
|
||||
.softener-fill-warning { background: var(--sev-warning); }
|
||||
.softener-fill-critical { background: var(--sev-critical); }
|
||||
.softener-fill-unknown { background: var(--card-border); height: 0 !important; }
|
||||
.softener-tank-pct {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
text-shadow: 0 1px 2px rgba(255,255,255,.6);
|
||||
}
|
||||
.softener-tank-label { font-size: 11px; font-weight: 600; color: var(--text-dark); margin-top: 6px; }
|
||||
.softener-tank-value { font-size: 10px; color: var(--text-mid); }
|
||||
|
||||
/* ── Badges ────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Plus } from 'lucide-react'
|
||||
import { fetchAssets, createAsset, updateAsset, fetchAssetPhotos } from '../api'
|
||||
import type { PlantAsset, AssetPhoto } from '../types'
|
||||
import { fetchAssets, createAsset, updateAsset, fetchAssetPhotos, fetchAssetLatest } from '../api'
|
||||
import type { PlantAsset, AssetPhoto, TelemetryField, WaterSoftenerDiagramConfig } from '../types'
|
||||
import { ASSET_TYPES, ASSET_TYPE_LABELS } from '../types'
|
||||
import AssetPhotoUpload from '../components/AssetPhotoUpload'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
|
|
@ -12,6 +12,19 @@ const BLANK_FORM = {
|
|||
serial_no: '', install_date: '', notes: '', mqtt_topic_prefix: '',
|
||||
}
|
||||
|
||||
const DEFAULT_DIAGRAM_CONFIG: WaterSoftenerDiagramConfig = {
|
||||
enabled: false,
|
||||
tank_count: 2,
|
||||
tank_a_field: null,
|
||||
tank_a_max_l: null,
|
||||
tank_b_field: null,
|
||||
tank_b_max_l: null,
|
||||
tank_in_service_field: null,
|
||||
tank_b_when_truthy: true,
|
||||
brine_level_field: null,
|
||||
regen_active_field: null,
|
||||
}
|
||||
|
||||
export default function Assets() {
|
||||
const { user } = useAuth()
|
||||
const canManage = can(user, 'manage_assets')
|
||||
|
|
@ -23,6 +36,9 @@ export default function Assets() {
|
|||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
const [photosByAsset, setPhotosByAsset] = useState<Record<number, AssetPhoto[]>>({})
|
||||
const [editForm, setEditForm] = useState<Record<string, string>>({})
|
||||
const [fieldsByAsset, setFieldsByAsset] = useState<Record<number, TelemetryField[]>>({})
|
||||
const [diagramForm, setDiagramForm] = useState<WaterSoftenerDiagramConfig>(DEFAULT_DIAGRAM_CONFIG)
|
||||
const [savingDiagram, setSavingDiagram] = useState(false)
|
||||
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [newForm, setNewForm] = useState(BLANK_FORM)
|
||||
|
|
@ -48,6 +64,28 @@ export default function Assets() {
|
|||
const photos = await fetchAssetPhotos(a.id).catch(() => [])
|
||||
setPhotosByAsset(prev => ({ ...prev, [a.id]: photos }))
|
||||
}
|
||||
if (a.asset_type === 'water_softener') {
|
||||
setDiagramForm({ ...DEFAULT_DIAGRAM_CONFIG, ...(a.diagram_config || {}) })
|
||||
if (!fieldsByAsset[a.id]) {
|
||||
const fields = await fetchAssetLatest(a.id).catch(() => [])
|
||||
setFieldsByAsset(prev => ({ ...prev, [a.id]: fields }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDiagram(id: number) {
|
||||
setError(''); setMsg('')
|
||||
setSavingDiagram(true)
|
||||
try {
|
||||
await updateAsset(id, { diagram_config: diagramForm })
|
||||
setMsg('Diagram config saved')
|
||||
setTimeout(() => setMsg(''), 2000)
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
} finally {
|
||||
setSavingDiagram(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadPhotos(id: number) {
|
||||
|
|
@ -257,6 +295,134 @@ export default function Assets() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{a.asset_type === 'water_softener' && canManage && (
|
||||
<>
|
||||
<div className="section-title" style={{ margin: '4px 0 8px' }}>Dashboard diagram</div>
|
||||
<div className="field-row">
|
||||
<div className="field field-check" style={{ flex: '0 0 auto' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox" checked={diagramForm.enabled}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, enabled: e.target.checked })}
|
||||
/>
|
||||
Show diagram on Dashboard
|
||||
</label>
|
||||
</div>
|
||||
<div className="field" style={{ flex: '0 0 140px' }}>
|
||||
<label>Resin tanks</label>
|
||||
<select
|
||||
value={diagramForm.tank_count}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, tank_count: Number(e.target.value) as 1 | 2 })}
|
||||
>
|
||||
<option value={1}>1 tank</option>
|
||||
<option value={2}>2 tanks (A/B)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>{diagramForm.tank_count === 1 ? 'Tank capacity field' : 'Tank A capacity field'}</label>
|
||||
<select
|
||||
value={diagramForm.tank_a_field || ''}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, tank_a_field: e.target.value || null })}
|
||||
>
|
||||
<option value="">— not mapped —</option>
|
||||
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>{diagramForm.tank_count === 1 ? 'Tank max capacity (L)' : 'Tank A max capacity (L)'}</label>
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={diagramForm.tank_a_max_l ?? ''}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, tank_a_max_l: e.target.value ? Number(e.target.value) : null })}
|
||||
placeholder="e.g. 2000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagramForm.tank_count === 2 && (
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Tank B capacity field</label>
|
||||
<select
|
||||
value={diagramForm.tank_b_field || ''}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, tank_b_field: e.target.value || null })}
|
||||
>
|
||||
<option value="">— not mapped —</option>
|
||||
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Tank B max capacity (L)</label>
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={diagramForm.tank_b_max_l ?? ''}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, tank_b_max_l: e.target.value ? Number(e.target.value) : null })}
|
||||
placeholder="e.g. 2000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Brine tank level field</label>
|
||||
<select
|
||||
value={diagramForm.brine_level_field || ''}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, brine_level_field: e.target.value || null })}
|
||||
>
|
||||
<option value="">— not mapped —</option>
|
||||
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
|
||||
</select>
|
||||
<div className="field-hint">Expected as a 0–100% value.</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Regeneration active field</label>
|
||||
<select
|
||||
value={diagramForm.regen_active_field || ''}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, regen_active_field: e.target.value || null })}
|
||||
>
|
||||
<option value="">— not mapped —</option>
|
||||
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagramForm.tank_count === 2 && (
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Tank in service field</label>
|
||||
<select
|
||||
value={diagramForm.tank_in_service_field || ''}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, tank_in_service_field: e.target.value || null })}
|
||||
>
|
||||
<option value="">— not mapped —</option>
|
||||
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field field-check" style={{ flex: '0 0 auto', alignSelf: 'flex-end', marginBottom: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox" checked={diagramForm.tank_b_when_truthy}
|
||||
onChange={e => setDiagramForm({ ...diagramForm, tank_b_when_truthy: e.target.checked })}
|
||||
/>
|
||||
On means Tank B in service
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-primary btn-sm" disabled={savingDiagram}
|
||||
onClick={() => saveDiagram(a.id)} style={{ marginBottom: 16 }}
|
||||
>
|
||||
{savingDiagram ? 'Saving…' : 'Save diagram config'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="field-row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="field-hint" style={{ marginBottom: 4 }}>Asset photo</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { AlertTriangle, WifiOff } from 'lucide-react'
|
|||
import { fetchStatus } from '../api'
|
||||
import type { AssetStatus, AssetType } from '../types'
|
||||
import { ASSET_TYPES, ASSET_TYPE_LABELS } from '../types'
|
||||
import WaterSoftenerDiagram from '../components/WaterSoftenerDiagram'
|
||||
|
||||
const POLL_MS = 30000
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ function AssetCard({ asset }: { asset: AssetStatus }) {
|
|||
{asset.location || 'No location set'}
|
||||
{asset.mqtt_topic_prefix ? '' : ' · not wired to MQTT yet'}
|
||||
</div>
|
||||
{asset.asset_type === 'water_softener' && <WaterSoftenerDiagram asset={asset} />}
|
||||
{asset.latest.length === 0 ? (
|
||||
<div className="muted" style={{ fontSize: 12.5 }}>No telemetry received yet.</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -20,6 +20,24 @@ export const CONDITION_LABELS: Record<AlertCondition, string> = {
|
|||
stale_minutes: 'Stale for (minutes, no update)',
|
||||
}
|
||||
|
||||
// Diagram-config field mappings are all "which field_key on this asset holds
|
||||
// this value" — kept generic (a plain field_key string) rather than a fixed
|
||||
// enum so the diagram degrades gracefully if the device's topic naming ever
|
||||
// changes; the Assets page just re-populates the dropdown from whatever
|
||||
// field_keys plant_asset_latest currently has for that asset.
|
||||
export interface WaterSoftenerDiagramConfig {
|
||||
enabled: boolean
|
||||
tank_count: 1 | 2
|
||||
tank_a_field: string | null
|
||||
tank_a_max_l: number | null
|
||||
tank_b_field: string | null
|
||||
tank_b_max_l: number | null
|
||||
tank_in_service_field: string | null
|
||||
tank_b_when_truthy: boolean // whether a truthy tank_in_service value means "Tank B in service" (flip if it renders backwards)
|
||||
brine_level_field: string | null
|
||||
regen_active_field: string | null
|
||||
}
|
||||
|
||||
export interface PlantAsset {
|
||||
id: number
|
||||
name: string
|
||||
|
|
@ -33,6 +51,7 @@ export interface PlantAsset {
|
|||
mqtt_topic_prefix: string | null
|
||||
created_at: string
|
||||
photo_count?: number
|
||||
diagram_config: WaterSoftenerDiagramConfig | null
|
||||
}
|
||||
|
||||
export interface TelemetryField {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue