signalk-wiring 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,10 @@ to someone else doesn't depend on the current owner being present.
66
66
  strip, and a red-shifted **night theme** that protects night vision,
67
67
  switched by a button or, with the `autoTheme` option, by the vessel's
68
68
  `environment.sun` or `environment.mode`.
69
+ - **AC and DC aware**: mark devices AC or DC (new devices get it from their
70
+ type) and a new cable's type is chosen from its two ends, with a warning when
71
+ they disagree. A cable cannot carry both AC and DC, a conduit holding both
72
+ gets a warning, and AC cables are drawn dashed and DC solid.
69
73
  - **Schematics**: several named diagrams of the electrical system. Place
70
74
  devices (or create them from a palette by role), drag a wire from one to
71
75
  another and the cable and its circuit exist; wires are right-angled and
@@ -154,6 +158,9 @@ npm install
154
158
  npm test
155
159
  ```
156
160
 
161
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to report issues, plan larger
162
+ changes and send pull requests.
163
+
157
164
  The webapp (`public/`) uses [Preact](https://preactjs.com/) and
158
165
  [`htm`](https://github.com/developit/htm) (JSX-like syntax without a
159
166
  build step) plus [`dagre`](https://github.com/dagrejs/dagre) for diagram
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-wiring",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "SignalK plugin for documenting a boat's physical electrical wiring: circuits, wire runs, and devices.",
5
5
  "main": "index.js",
6
6
  "files": [
package/public/app.js CHANGED
@@ -22,7 +22,8 @@ import { deviceBox, endTerminals } from './schematic.js'
22
22
  import { PortBlocks } from './ports.js'
23
23
  import { useSettings, colorPlaceholder } from './settings.js'
24
24
  import { useTheme } from './theme.js'
25
- import { typeOptions, isCriticalType } from './deviceTypes.js'
25
+ import { typeOptions, isCriticalType, typeCurrent } from './deviceTypes.js'
26
+ import { suggestKind, mismatchText, conduitCurrentMix, conduitMixText } from './current.js'
26
27
  import { describeMeasured, describeGroup } from './measured.js'
27
28
  import { useLive } from './live.js'
28
29
  import { CalibrateDialog } from './calibrate.js'
@@ -59,13 +60,25 @@ async function fetchResource (type, id) {
59
60
  return res.json()
60
61
  }
61
62
 
63
+ // A refused write (400) says why in its body, for example a cable that mixes
64
+ // AC and DC; anything else keeps the generic text.
65
+ async function refusalMessage (res, fallback) {
66
+ if (res.status !== 400 || typeof res.json !== 'function') return fallback
67
+ try {
68
+ const body = await res.json()
69
+ return body && typeof body.message === 'string' && body.message ? body.message : fallback
70
+ } catch (_) {
71
+ return fallback
72
+ }
73
+ }
74
+
62
75
  async function putResource (type, id, value) {
63
76
  const res = await fetch(`${RESOURCE_BASE}/${type}/${id}`, {
64
77
  method: 'PUT',
65
78
  headers: { 'Content-Type': 'application/json' },
66
79
  body: JSON.stringify(value)
67
80
  })
68
- if (!res.ok) throw new Error(apiErrorMessage(res.status))
81
+ if (!res.ok) throw new Error(await refusalMessage(res, apiErrorMessage(res.status)))
69
82
  }
70
83
 
71
84
  // Undo of a removal: a plain write of status active, which the store logs as a restore.
@@ -406,7 +419,7 @@ function Form ({ fields, initial, onSubmit, onCancel, submitLabel = 'Save', cont
406
419
  `}
407
420
  ${f.type === 'select' && html`
408
421
  <select value=${values[f.key]} onChange=${(e) => setField(f.key, e.target.value)}>
409
- ${!f.noBlank && html`<option value="">—</option>`}
422
+ ${!f.noBlank && html`<option value="">${f.blankLabel || '—'}</option>`}
410
423
  ${f.options.map((o) => {
411
424
  const option = typeof o === 'string' ? { value: o, label: o } : o
412
425
  return html`<option value=${option.value} key=${option.value}>${option.label}</option>`
@@ -667,6 +680,13 @@ function criticalDefault (key, value, values, touched) {
667
680
  return extra
668
681
  }
669
682
 
683
+ // For a new device the Current select follows the type until the user sets it.
684
+ function newDeviceDefaults (key, value, values, touched) {
685
+ const extra = criticalDefault(key, value, values, touched) || {}
686
+ if (key === 'type' && !touched.current) extra.current = typeCurrent(value) || ''
687
+ return extra
688
+ }
689
+
670
690
  const DEVICE_FIELDS = [
671
691
  { key: 'name', label: 'Name', type: 'text', required: true },
672
692
  {
@@ -696,6 +716,15 @@ const DEVICE_FIELDS = [
696
716
  type: 'tags',
697
717
  hint: 'Anything else you want to group by, such as an audio zone. Free text, several allowed, separated by commas.'
698
718
  },
719
+ {
720
+ key: 'current',
721
+ label: 'Current',
722
+ type: 'select',
723
+ options: [{ value: 'ac', label: 'AC' }, { value: 'dc', label: 'DC' }],
724
+ blankLabel: 'Either',
725
+ clearable: true,
726
+ hint: 'Whether this device is AC or DC. Cables drawn to it get the matching cable type, and a warning appears when the two ends disagree. Starts from the type (a battery is DC, a shore power inlet AC). Leave it on Either for converters and for devices whose terminals say it already.'
727
+ },
699
728
  { key: 'ratedPowerW', label: 'Rated power (W)', type: 'number' },
700
729
  {
701
730
  key: 'tripRatingA',
@@ -1021,7 +1050,25 @@ function CableForm ({ cable, prefill, devices, cables, circuits, onDevicesChange
1021
1050
  const [more, setMore] = useState(() => (cable || prefill) ? kindByValue(values.kind).more : false)
1022
1051
  const [saving, setSaving] = useState(false)
1023
1052
  const [error, setError] = useState(null)
1053
+ // Until the user picks a kind themselves, the kind follows the two ends. A
1054
+ // cable being edited, or one that arrives with a kind chosen elsewhere
1055
+ // (the quick popover), keeps its kind.
1056
+ const [kindTouched, setKindTouched] = useState(Boolean(cable || (prefill && prefill.kind)))
1024
1057
  const set = (key, value) => setValues((v) => ({ ...v, [key]: value }))
1058
+ const setKind = (kind) => { setKindTouched(true); set('kind', kind) }
1059
+ const setEnd = (key, id) => {
1060
+ const next = { ...values, [key]: id }
1061
+ if (!kindTouched && !cable) {
1062
+ const from = devices.find((d) => d.id === next.fromDeviceId)
1063
+ const to = devices.find((d) => d.id === next.toDeviceId)
1064
+ if (from && to) {
1065
+ const { kind } = suggestKind(from, '', to, '')
1066
+ if (kindByValue(kind).more) setMore(true)
1067
+ next.kind = kind
1068
+ }
1069
+ }
1070
+ setValues(next)
1071
+ }
1025
1072
  const setColor = (conductor, value) => setValues((v) => ({ ...v, colors: { ...v.colors, [conductor]: value } }))
1026
1073
  const setTerminal = (conductor, end, value) => setValues((v) => ({
1027
1074
  ...v,
@@ -1035,6 +1082,7 @@ function CableForm ({ cable, prefill, devices, cables, circuits, onDevicesChange
1035
1082
  ? values.existingConductors.map((c) => c.conductor)
1036
1083
  : kindByValue(values.kind).conductors
1037
1084
  const askTerminals = needsTerminal(fromDevice) || needsTerminal(toDevice)
1085
+ const mismatch = !cable && fromDevice && toDevice ? suggestKind(fromDevice, '', toDevice, '').mismatch : null
1038
1086
  const kinds = CABLE_KINDS.filter((k) => !k.more || more || k.value === values.kind)
1039
1087
 
1040
1088
  const submit = async (e) => {
@@ -1058,20 +1106,21 @@ function CableForm ({ cable, prefill, devices, cables, circuits, onDevicesChange
1058
1106
  <form class="inline-form cable-form" onSubmit=${submit}>
1059
1107
  <${ErrorBanner} message=${error} />
1060
1108
  <${DevicePicker} label="From (supply side)" required=${true} value=${values.fromDeviceId}
1061
- onChange=${(v) => set('fromDeviceId', v)} devices=${devices} onCreated=${onDevicesChanged}
1109
+ onChange=${(v) => setEnd('fromDeviceId', v)} devices=${devices} onCreated=${onDevicesChanged}
1062
1110
  defaultRole="distribution"
1063
1111
  hint="The side the power comes from: a fuse, breaker, bus bar, battery or source." />
1064
1112
  <${DevicePicker} label="To (load side)" required=${true} value=${values.toDeviceId}
1065
- onChange=${(v) => set('toDeviceId', v)} devices=${devices} onCreated=${onDevicesChanged}
1113
+ onChange=${(v) => setEnd('toDeviceId', v)} devices=${devices} onCreated=${onDevicesChanged}
1066
1114
  defaultRole="consumer" />
1067
1115
 
1068
1116
  <div class="form-field">
1069
1117
  <span>Conductors</span>
1118
+ ${mismatch && html`<div class="warning-banner" data-testid="current-mismatch">${mismatchText(mismatch, fromDevice, toDevice)}</div>`}
1070
1119
  <div class="kind-choice">
1071
1120
  ${kinds.map((k) => html`
1072
1121
  <label key=${k.value} class="radio-chip">
1073
1122
  <input type="radio" name="cable-kind" value=${k.value} checked=${values.kind === k.value}
1074
- onChange=${() => set('kind', k.value)} />
1123
+ onChange=${() => setKind(k.value)} />
1075
1124
  ${k.label}
1076
1125
  </label>
1077
1126
  `)}
@@ -1767,7 +1816,7 @@ function DevicesList ({ onSelect }) {
1767
1816
  ${showForm && html`
1768
1817
  <${Form}
1769
1818
  fields=${DEVICE_FIELDS}
1770
- onFieldChange=${criticalDefault}
1819
+ onFieldChange=${newDeviceDefaults}
1771
1820
  onSubmit=${createDevice}
1772
1821
  onCancel=${() => setShowForm(false)}
1773
1822
  submitLabel="Create device"
@@ -1886,6 +1935,7 @@ function DeviceDetail ({ deviceId, onBack, onNotFound = () => {} }) {
1886
1935
  <ul>${device.terminals.map((t, i) => html`<li key=${i}>${describeTerminal(t)}</li>`)}</ul>
1887
1936
  </div>
1888
1937
  `}
1938
+ ${device.current && html`<div class="meta">Current: ${device.current === 'ac' ? 'AC' : 'DC'}</div>`}
1889
1939
  ${device.signalkPath && html`
1890
1940
  <div class="meta">SignalK path: <code>${device.signalkPath}</code></div>
1891
1941
  `}
@@ -1949,6 +1999,9 @@ function ConduitsList ({ onSelect }) {
1949
1999
  const [reloadKey, setReloadKey] = useState(0)
1950
2000
  const [showForm, setShowForm] = useState(false)
1951
2001
  const { loading, error, items } = useResourceList('wiringConduits', reloadKey)
2002
+ const { items: allRuns } = useResourceList('wiringWireRuns', reloadKey)
2003
+ const runById = new Map(allRuns.map((r) => [r.id, r]))
2004
+ const mixesOf = (conduit) => conduitCurrentMix((conduit.cableIds || []).map((id) => runById.get(id)).filter(Boolean)).mixed
1952
2005
  const [filter, setFilter] = useState({})
1953
2006
  const { suggested, used } = kindOptions(items.map((c) => c.kind), '')
1954
2007
  const visible = filterConduits(items, filter)
@@ -2000,6 +2053,7 @@ function ConduitsList ({ onSelect }) {
2000
2053
  <div>
2001
2054
  <div>${conduit.name}</div>
2002
2055
  <div class="meta">${conduitSummary(conduit)}${conduit.zone ? ` – ${conduit.zone}` : ''} – ${(conduit.cableIds || []).length === 1 ? '1 cable' : `${(conduit.cableIds || []).length} cables`}</div>
2056
+ ${mixesOf(conduit) && html`<div class="meta" style="color: var(--warn)" data-testid="conduit-mix-line">Mixes AC and DC</div>`}
2003
2057
  </div>
2004
2058
  <${StatusBadge} status=${conduit.status} />
2005
2059
  </div>
@@ -2016,6 +2070,7 @@ function ConduitDetail ({ conduitId, onBack, onNotFound = () => {} }) {
2016
2070
  const [editing, setEditing] = useState(false)
2017
2071
  const [showHistory, setShowHistory] = useState(false)
2018
2072
  const [toAdd, setToAdd] = useState('')
2073
+ const [mixNotice, setMixNotice] = useState(null)
2019
2074
  const { items: runs } = useResourceList('wiringWireRuns', reloadKey)
2020
2075
  const { items: devices } = useResourceList('wiringDevices')
2021
2076
  const { items: conduits } = useResourceList('wiringConduits', reloadKey)
@@ -2048,12 +2103,17 @@ function ConduitDetail ({ conduitId, onBack, onNotFound = () => {} }) {
2048
2103
  onBack() // leaving the page first: it would otherwise finish the message below
2049
2104
  undo.offer({ message: `Conduit "${name}" removed.`, onUndo: () => restoreResource('wiringConduits', conduitId) })
2050
2105
  }
2051
- const setCables = (cableIds) => guard(() => putResource('wiringConduits', conduitId, { cableIds }))
2106
+ const setCables = (cableIds) => { setMixNotice(null); return guard(() => putResource('wiringConduits', conduitId, { cableIds })) }
2052
2107
  const addCable = () => {
2053
2108
  if (!toAdd) return
2054
2109
  const id = toAdd
2055
2110
  setToAdd('')
2056
- return setCables([...(conduit.cableIds || []), id])
2111
+ const cableIds = [...(conduit.cableIds || []), id]
2112
+ const mix = conduitCurrentMix(cableIds.map((cid) => runById.get(cid)).filter(Boolean))
2113
+ // Never blocks: the cable is added, and the warning is shown after.
2114
+ const done = setCables(cableIds)
2115
+ setMixNotice(mix.mixed ? conduitMixText(mix, (r) => cableName(r, deviceNameById)) : null)
2116
+ return done
2057
2117
  }
2058
2118
  // Moves this conduit one place earlier or later in the cable's own list.
2059
2119
  const moveForCable = (run, delta) =>
@@ -2061,6 +2121,7 @@ function ConduitDetail ({ conduitId, onBack, onNotFound = () => {} }) {
2061
2121
 
2062
2122
  const cables = conduit ? (conduit.cableIds || []).map((id) => runById.get(id)).filter(Boolean) : []
2063
2123
  const candidates = conduit ? addableCables(runs, conduit) : []
2124
+ const mixText = conduitMixText(conduitCurrentMix(cables), (r) => cableName(r, deviceNameById))
2064
2125
 
2065
2126
  return html`
2066
2127
  <div>
@@ -2094,7 +2155,9 @@ function ConduitDetail ({ conduitId, onBack, onNotFound = () => {} }) {
2094
2155
  </div>
2095
2156
  `}
2096
2157
  ${showHistory && html`<${ChangeLog} kind="conduit" id=${conduitId} />`}
2158
+ ${mixNotice && html`<div class="warning-banner" style="white-space: pre-line" data-testid="conduit-mix-notice"><strong>Added, but check this:</strong> ${mixNotice}</div>`}
2097
2159
  ${conduit && html`
2160
+ ${mixText && !mixNotice && html`<div class="warning-banner" style="white-space: pre-line" data-testid="conduit-mix-warning">${mixText}</div>`}
2098
2161
  <h3>Cables in this conduit (${cables.length})</h3>
2099
2162
  ${cables.length === 0 && html`<div class="empty-state">No cables assigned yet.</div>`}
2100
2163
  ${cables.map((run) => {
@@ -1,5 +1,6 @@
1
1
  import { html, useState } from './vendor/preact-standalone.module.js'
2
2
  import { CABLE_KINDS, buildCablePayload } from './cableForm.js'
3
+ import { mismatchText } from './current.js'
3
4
  import { popoverKinds, quickProblem, withKind } from './quickCable.js'
4
5
 
5
6
  // The small popover that asks how a freshly drawn wire is made: Positive,
@@ -14,7 +15,7 @@ export function CablePopover ({ start, lengthNote, style, onCreate, onMore, onCa
14
15
  const [saving, setSaving] = useState(false)
15
16
  const [error, setError] = useState(null)
16
17
  const set = (key, value) => setValues((v) => ({ ...v, [key]: value }))
17
- const kinds = popoverKinds(start.values.kind, CABLE_KINDS)
18
+ const kinds = popoverKinds(start.values.kind, CABLE_KINDS, start.mismatch)
18
19
 
19
20
  const create = async () => {
20
21
  const problem = quickProblem(start, values)
@@ -33,6 +34,7 @@ export function CablePopover ({ start, lengthNote, style, onCreate, onMore, onCa
33
34
  <div class="cable-popover" style=${style} data-testid="cable-popover"
34
35
  onPointerDown=${(e) => e.stopPropagation()}>
35
36
  <div class="cable-popover-title">${start.from.name} to ${start.to.name}</div>
37
+ ${start.mismatch && html`<div class="warning-banner" data-testid="current-mismatch">${mismatchText(start.mismatch, start.from, start.to)}</div>`}
36
38
  <div class="cable-popover-kinds">
37
39
  ${kinds.map((k) => html`
38
40
  <label key=${k.value} class="radio-line">
@@ -0,0 +1,113 @@
1
+ // AC and DC awareness (plan 0019): which current each end of a cable carries
2
+ // and which cable type that suggests. Pure. The server has the type table and
3
+ // mixesAcAndDc in src/current.js; tests keep both in step.
4
+
5
+ import { defaultKind } from './schematic.js'
6
+
7
+ // 'ac' for line, neutral and earth, 'dc' for positive and negative, null for
8
+ // signal or anything else.
9
+ export function conductorCurrent (conductorType) {
10
+ if (conductorType === 'line' || conductorType === 'neutral' || conductorType === 'earth') return 'ac'
11
+ if (conductorType === 'positive' || conductorType === 'negative') return 'dc'
12
+ return null
13
+ }
14
+
15
+ // The current a device's terminals agree on: all AC or all DC. Mixed, signal
16
+ // only or none is unknown.
17
+ function terminalsCurrent (device) {
18
+ const terminals = device.terminals || []
19
+ if (terminals.length === 0) return null
20
+ const set = new Set(terminals.map((t) => conductorCurrent(t.conductor)))
21
+ return set.size === 1 && !set.has(null) ? [...set][0] : null
22
+ }
23
+
24
+ // The current at one end of a cable: the terminal dropped on decides, else
25
+ // the device mark, else what all its terminals agree on, else null.
26
+ export function endCurrent (device, terminalName) {
27
+ if (!device) return null
28
+ if (terminalName) {
29
+ const terminal = (device.terminals || []).find((t) => t.name === terminalName)
30
+ if (terminal) {
31
+ if (terminal.conductor === 'signal') return null
32
+ const fromTerminal = conductorCurrent(terminal.conductor)
33
+ if (fromTerminal) return fromTerminal
34
+ }
35
+ }
36
+ if (device.current === 'ac' || device.current === 'dc') return device.current
37
+ return terminalsCurrent(device)
38
+ }
39
+
40
+ // The cable type to start with for a new cable From a device (the supply end)
41
+ // To another. `mismatch` is null, or { fromCurrent, toCurrent } when both ends
42
+ // are known and differ; the kind then follows the From end.
43
+ export function suggestKind (from, fromTerminal, to, toTerminal) {
44
+ const terminals = [...(from.terminals || []), ...(to.terminals || [])]
45
+ if (terminals.length > 0 && terminals.every((t) => t.conductor === 'signal')) {
46
+ return { kind: 'signal', mismatch: null }
47
+ }
48
+ const fromCurrent = endCurrent(from, fromTerminal)
49
+ const toCurrent = endCurrent(to, toTerminal)
50
+ if (!fromCurrent && !toCurrent) return { kind: defaultKind(from, to), mismatch: null }
51
+ const current = fromCurrent || toCurrent
52
+ const mismatch = fromCurrent && toCurrent && fromCurrent !== toCurrent ? { fromCurrent, toCurrent } : null
53
+ if (current === 'ac') return { kind: 'ac', mismatch }
54
+ const lone = from.role === 'distribution' || to.role === 'distribution'
55
+ return { kind: lone ? 'positive' : 'both', mismatch }
56
+ }
57
+
58
+ // True when the conductors (objects with a `conductor`, or plain type names)
59
+ // include a line or neutral one and a positive or negative one. Earth and
60
+ // signal never count.
61
+ export function mixesAcAndDc (conductors) {
62
+ const types = (Array.isArray(conductors) ? conductors : []).map((c) => (c && c.conductor) || c)
63
+ return types.some((t) => t === 'line' || t === 'neutral') &&
64
+ types.some((t) => t === 'positive' || t === 'negative')
65
+ }
66
+
67
+ // 'ac' when the cable has a line or neutral conductor, 'dc' when it has a
68
+ // positive or negative one, null otherwise. A cable with no conductors listed
69
+ // is a single positive, so dc.
70
+ export function cableCurrent (cable) {
71
+ const types = ((cable && cable.conductors) || []).map((c) => c.conductor)
72
+ if (types.length === 0) return 'dc'
73
+ if (types.some((t) => t === 'line' || t === 'neutral')) return 'ac'
74
+ if (types.some((t) => t === 'positive' || t === 'negative')) return 'dc'
75
+ return null
76
+ }
77
+
78
+ // The warning shown above the kind choice when the two ends disagree, using
79
+ // the device names. Null when there is no mismatch.
80
+ export function mismatchText (mismatch, from, to) {
81
+ if (!mismatch) return null
82
+ const label = (c) => (c === 'ac' ? 'AC' : 'DC')
83
+ return `${from.name} is ${label(mismatch.fromCurrent)}, ${to.name} is ${label(mismatch.toCurrent)}. Choose the cable type.`
84
+ }
85
+
86
+ // The live (not removed) cables of a conduit sorted by current: `ac` and `dc`
87
+ // hold the cables, `mixed` is true only when both are non-empty. Cables that
88
+ // are neither (signal only, earth only) count as neither.
89
+ export function conduitCurrentMix (cables) {
90
+ const ac = []
91
+ const dc = []
92
+ for (const cable of cables || []) {
93
+ if (!cable || cable.status === 'removed') continue
94
+ const current = cableCurrent(cable)
95
+ if (current === 'ac') ac.push(cable)
96
+ else if (current === 'dc') dc.push(cable)
97
+ }
98
+ return { ac, dc, mixed: ac.length > 0 && dc.length > 0 }
99
+ }
100
+
101
+ // The warning for a mixed conduit: a heading, the advice and the cables named
102
+ // under AC and under DC, as lines joined with newlines. Null when not mixed.
103
+ // `cableName` turns a cable into the name shown.
104
+ export function conduitMixText (mix, cableName) {
105
+ if (!mix || !mix.mixed) return null
106
+ const names = (list) => list.map((c) => cableName(c)).join(', ')
107
+ return [
108
+ 'This conduit holds AC and DC cables.',
109
+ 'Keep them in separate conduits where you can. If they share one, the AC must be in its own sheathed cable, all insulation rated for the highest voltage present, and ideally a partition.',
110
+ `AC: ${names(mix.ac)}`,
111
+ `DC: ${names(mix.dc)}`
112
+ ].join('\n')
113
+ }
@@ -54,3 +54,23 @@ export function isCriticalType (type) {
54
54
  const text = type.trim().toLowerCase()
55
55
  return text !== '' && CRITICAL_TYPE_WORDS.some((word) => text.includes(word))
56
56
  }
57
+
58
+ // Device types that are plainly AC or DC (plan 0019), matched ignoring case on
59
+ // the whole type name. Converters, distribution devices and consumers are not
60
+ // listed: their ports or the supply side decide. The same table is in
61
+ // src/current.js (the store applies it too); a test keeps both equal.
62
+ export const AC_TYPES = ['Shore power inlet', 'Generator', 'Isolation transformer']
63
+ export const DC_TYPES = [
64
+ 'Solar panel', 'Alternator', 'Wind generator', 'Hydro generator', 'Fuel cell',
65
+ 'Battery', 'Battery bank', 'Lead-acid battery', 'AGM battery', 'LiFePO4 battery',
66
+ 'Starter battery', 'House battery', 'Shunt', 'Battery isolator', 'Battery switch'
67
+ ]
68
+
69
+ // 'ac', 'dc' or null for a type name.
70
+ export function typeCurrent (type) {
71
+ if (typeof type !== 'string') return null
72
+ const text = type.trim().toLowerCase()
73
+ if (AC_TYPES.some((t) => t.toLowerCase() === text)) return 'ac'
74
+ if (DC_TYPES.some((t) => t.toLowerCase() === text)) return 'dc'
75
+ return null
76
+ }
@@ -9,7 +9,7 @@ import {
9
9
  escapeStep, deleteStep
10
10
  } from './floorplan.js'
11
11
  import { hashForState } from './router.js'
12
- import { isTypingTarget } from './schematic.js'
12
+ import { isTypingTarget, wireLineStyle, scaledDash } from './schematic.js'
13
13
  import { useUndo } from './undoBar.js'
14
14
  import { offerCableUndo } from './canvasRemoval.js'
15
15
  import { cableName } from './conduits.js'
@@ -41,13 +41,25 @@ async function fetchList (type) {
41
41
  return Object.values(await res.json())
42
42
  }
43
43
 
44
+ // A refused write (400) says why in its body, for example a cable that mixes
45
+ // AC and DC; anything else keeps the generic text.
46
+ async function refusalMessage (res, fallback) {
47
+ if (res.status !== 400 || typeof res.json !== 'function') return fallback
48
+ try {
49
+ const body = await res.json()
50
+ return body && typeof body.message === 'string' && body.message ? body.message : fallback
51
+ } catch (_) {
52
+ return fallback
53
+ }
54
+ }
55
+
44
56
  async function putResource (type, id, value) {
45
57
  const res = await fetch(`${RESOURCE_BASE}/${type}/${id}`, {
46
58
  method: 'PUT',
47
59
  headers: { 'Content-Type': 'application/json' },
48
60
  body: JSON.stringify(value)
49
61
  })
50
- if (!res.ok) throw new Error(failureMessage(res.status))
62
+ if (!res.ok) throw new Error(await refusalMessage(res, failureMessage(res.status)))
51
63
  }
52
64
 
53
65
  async function deleteResource (type, id) {
@@ -394,7 +406,7 @@ function PlanViewer ({ plan, data, ctx, actions, onSaved, onError }) {
394
406
  return ownRoute && key >= 0 && isActive ? pxFor('waypoint', key, p) : (planShowsPoint(plan, p) ? pointToPx(cal, plan.view, p) : null)
395
407
  })
396
408
  for (const seg of visibleSegments(pxs)) {
397
- runLines.push({ id: run.id, active: isActive, highlighted: inCircuit, points: seg.map((p) => p.join(',')).join(' ') })
409
+ runLines.push({ id: run.id, dash: wireLineStyle(run).dasharray, active: isActive, highlighted: inCircuit, points: seg.map((p) => p.join(',')).join(' ') })
398
410
  }
399
411
  addContinuations(run.id, points)
400
412
  if (isActive && ownRoute) {
@@ -497,7 +509,8 @@ function PlanViewer ({ plan, data, ctx, actions, onSaved, onError }) {
497
509
  ${runLines.map((l, i) => html`
498
510
  <polyline key=${l.id + ':' + i} points=${l.points} fill="none"
499
511
  class="run-line ${l.active ? 'active' : ''} ${l.highlighted ? 'highlighted' : ''}"
500
- stroke-width=${unit * (l.active ? 0.9 : 0.5)} />
512
+ stroke-width=${unit * (l.active ? 0.9 : 0.5)}
513
+ stroke-dasharray=${scaledDash(l.dash, unit * (l.active ? 0.9 : 0.5) / 3) || undefined} />
501
514
  `)}
502
515
 
503
516
  ${connectLines.map((points, i) => html`
package/public/import.js CHANGED
@@ -352,9 +352,16 @@ async function putResource (type, id, value) {
352
352
  body: JSON.stringify(value)
353
353
  })
354
354
  if (!res.ok) {
355
- const message = res.status === 401
355
+ let message = res.status === 401
356
356
  ? 'You need to be logged into the SignalK admin UI to do this.'
357
357
  : `Import failed (server responded ${res.status}).`
358
+ // A refused write says why (for example a cable that mixes AC and DC).
359
+ if (res.status === 400 && typeof res.json === 'function') {
360
+ try {
361
+ const body = await res.json()
362
+ if (body && typeof body.message === 'string' && body.message) message = body.message
363
+ } catch (_) { /* keep the generic message */ }
364
+ }
358
365
  throw new Error(message)
359
366
  }
360
367
  }
@@ -399,24 +406,34 @@ export async function importToStore (grouped) {
399
406
  devicesCreated++
400
407
  }
401
408
 
409
+ // A cable the store refuses (it mixes AC and DC, say) is reported as an
410
+ // error row and does not stop the others.
402
411
  const cableIdByTempId = new Map()
412
+ const rowErrors = []
413
+ const deviceName = (tempId) => (grouped.devices.find((d) => d.tempId === tempId) || {}).name || '?'
403
414
  for (const wr of grouped.wireRuns) {
404
415
  const id = newId()
405
- await putResource('wiringWireRuns', id, {
406
- fromDeviceId: deviceIdByTempId.get(wr.fromTempId),
407
- toDeviceId: deviceIdByTempId.get(wr.toTempId),
408
- conductors: [{ conductor: wr.conductor || 'positive', color: wr.color || undefined }],
409
- gauge: wr.gauge ?? undefined,
410
- gaugeUnit: wr.gaugeUnit || undefined,
411
- length: wr.length ?? undefined,
412
- lengthUnit: wr.lengthUnit || undefined,
413
- zone: wr.zone || undefined,
414
- cableLabel: wr.cableLabel || undefined,
415
- switchRef: wr.switchRef || undefined,
416
- conduitIds: (wr.conduitTempIds || []).length > 0
417
- ? wr.conduitTempIds.map((t) => conduitIdByTempId.get(t))
418
- : undefined
419
- })
416
+ try {
417
+ await putResource('wiringWireRuns', id, {
418
+ fromDeviceId: deviceIdByTempId.get(wr.fromTempId),
419
+ toDeviceId: deviceIdByTempId.get(wr.toTempId),
420
+ conductors: [{ conductor: wr.conductor || 'positive', color: wr.color || undefined }],
421
+ gauge: wr.gauge ?? undefined,
422
+ gaugeUnit: wr.gaugeUnit || undefined,
423
+ length: wr.length ?? undefined,
424
+ lengthUnit: wr.lengthUnit || undefined,
425
+ zone: wr.zone || undefined,
426
+ cableLabel: wr.cableLabel || undefined,
427
+ switchRef: wr.switchRef || undefined,
428
+ conduitIds: (wr.conduitTempIds || []).length > 0
429
+ ? wr.conduitTempIds.map((t) => conduitIdByTempId.get(t))
430
+ : undefined
431
+ })
432
+ } catch (err) {
433
+ if (/logged into/.test(err.message)) throw err
434
+ rowErrors.push({ cable: `${deviceName(wr.fromTempId)} to ${deviceName(wr.toTempId)}`, message: err.message })
435
+ continue
436
+ }
420
437
  cableIdByTempId.set(wr.tempId, id)
421
438
  }
422
439
 
@@ -425,7 +442,8 @@ export async function importToStore (grouped) {
425
442
  return {
426
443
  devicesCreated,
427
444
  devicesMatched: grouped.devices.length - devicesCreated,
428
- wireRunsCreated: grouped.wireRuns.length,
445
+ wireRunsCreated: cableIdByTempId.size,
446
+ rowErrors,
429
447
  unconnectedRows: grouped.unconnectedRows || 0,
430
448
  circuitsNamed,
431
449
  conduitsCreated
@@ -618,6 +636,12 @@ export function CsvImport ({ onDone, onCancel }) {
618
636
  Imported ${result.devicesCreated} new device(s) (${result.devicesMatched} matched by name)
619
637
  and ${result.wireRunsCreated} cable(s)${result.circuitsNamed > 0 ? `; ${result.circuitsNamed} circuit(s) named from the circuit column` : ''}${result.conduitsCreated > 0 ? `, plus ${result.conduitsCreated} new conduit(s)` : ''}.
620
638
  </p>
639
+ ${(result.rowErrors || []).length > 0 && html`
640
+ <div class="error-banner" data-testid="import-row-errors">
641
+ ${result.rowErrors.length} cable(s) were not imported:
642
+ <ul>${result.rowErrors.map((e, i) => html`<li key=${i}>${e.cable}: ${e.message}</li>`)}</ul>
643
+ </div>
644
+ `}
621
645
  <div class="form-actions">
622
646
  <button type="button" onClick=${onDone}>Done</button>
623
647
  </div>
@@ -4,12 +4,14 @@
4
4
  // buildCablePayload, so validation and circuit derivation are shared.
5
5
 
6
6
  import { kindByValue, defaultNegativeLanding, needsTerminal, terminalOptions, validateCable } from './cableForm.js'
7
- import { supplyOrder, defaultKind } from './schematic.js'
7
+ import { supplyOrder } from './schematic.js'
8
+ import { suggestKind } from './current.js'
8
9
 
9
10
  // The kinds the popover offers: the three DC ones, plus the suggested kind
10
11
  // when it is an AC or earth one.
11
- export function popoverKinds (suggested, allKinds) {
12
- return allKinds.filter((k) => !k.more || k.value === suggested)
12
+ // With a mismatch the plain AC kind is offered too, so either side can be chosen.
13
+ export function popoverKinds (suggested, allKinds, mismatch = null) {
14
+ return allKinds.filter((k) => !k.more || k.value === suggested || (mismatch && k.value === 'ac'))
13
15
  }
14
16
 
15
17
  // For each conductor of the kind, the terminal to use at each end: the one
@@ -54,12 +56,13 @@ export function startValues ({ a, ta, b, tb, cables, defaults = {}, keepOrder =
54
56
  const swapped = from.id !== a.id
55
57
  const fromTerminal = swapped ? tb : ta
56
58
  const toTerminal = swapped ? ta : tb
57
- const kind = defaultKind(from, to)
59
+ const { kind, mismatch } = suggestKind(from, fromTerminal, to, toTerminal)
58
60
  return {
59
61
  from,
60
62
  to,
61
63
  fromTerminal: fromTerminal || '',
62
64
  toTerminal: toTerminal || '',
65
+ mismatch,
63
66
  values: {
64
67
  fromDeviceId: from.id,
65
68
  toDeviceId: to.id,
@@ -1,3 +1,4 @@
1
+ import { cableCurrent } from './current.js'
1
2
  // Pure geometry for the schematic canvas (plan 0016): device boxes and their
2
3
  // terminal anchors, right-angle wire routing with lane offsets, wires through
3
4
  // bend points, off-page stubs, hit testing, default conductors and the
@@ -544,6 +545,51 @@ export function conductorTypes (cable) {
544
545
  return types.length ? types : ['positive']
545
546
  }
546
547
 
548
+ // The long-dash pattern that marks AC cables, shared by the schematic and the
549
+ // floorplan (the floorplan scales it to its line width).
550
+ export const AC_DASH = [10, 6]
551
+
552
+ // How a cable's current shows: `current` is 'ac', 'dc' or null, `dasharray` a
553
+ // stroke-dasharray string (AC only, DC and the rest stay solid) and `marker`
554
+ // the midpoint symbol on the schematic ('ac' tilde, 'dc' symbol, null).
555
+ export function wireLineStyle (cable) {
556
+ const current = cableCurrent(cable)
557
+ return {
558
+ current,
559
+ dasharray: current === 'ac' ? AC_DASH.join(' ') : null,
560
+ marker: current
561
+ }
562
+ }
563
+
564
+ // The same dash pattern scaled by a factor, as a stroke-dasharray string.
565
+ export function scaledDash (dasharray, factor) {
566
+ if (!dasharray) return null
567
+ return dasharray.split(' ').map((n) => Math.round(Number(n) * factor * 100) / 100).join(' ')
568
+ }
569
+
570
+ // A copy of a polyline moved sideways by `dist` (positive is to the right of
571
+ // the direction of travel), corners mitred, for drawing the second conductor
572
+ // of a cable beside the first.
573
+ export function offsetPath (points, dist) {
574
+ if (points.length < 2) return points.map((p) => ({ ...p }))
575
+ const normals = []
576
+ for (let i = 1; i < points.length; i++) {
577
+ const dx = points[i].x - points[i - 1].x
578
+ const dy = points[i].y - points[i - 1].y
579
+ const len = Math.hypot(dx, dy)
580
+ normals.push(len > 0 ? { x: -dy / len, y: dx / len } : null)
581
+ }
582
+ const fallback = normals.find(Boolean) || { x: 0, y: 0 }
583
+ const at = (i) => normals[i] || normals[i - 1] || fallback
584
+ return points.map((p, i) => {
585
+ const a = i === 0 ? at(0) : at(i - 1)
586
+ const b = i === points.length - 1 ? a : at(i)
587
+ const dot = a.x * b.x + a.y * b.y
588
+ const k = dist / Math.max(1 + dot, 0.2)
589
+ return { x: p.x + (a.x + b.x) * k, y: p.y + (a.y + b.y) * k }
590
+ })
591
+ }
592
+
547
593
  // ---- Highlighting -----------------------------------------------------------
548
594
 
549
595
  // The devices and cables a circuit resource spans, for highlighting.
@@ -32,13 +32,25 @@ function failureMessage (status) {
32
32
  return `Request failed (server responded ${status}).`
33
33
  }
34
34
 
35
+ // A refused write (400) says why in its body, for example a cable that mixes
36
+ // AC and DC; anything else keeps the generic text.
37
+ async function refusalMessage (res, fallback) {
38
+ if (res.status !== 400 || typeof res.json !== 'function') return fallback
39
+ try {
40
+ const body = await res.json()
41
+ return body && typeof body.message === 'string' && body.message ? body.message : fallback
42
+ } catch (_) {
43
+ return fallback
44
+ }
45
+ }
46
+
35
47
  async function putResource (type, id, value) {
36
48
  const res = await fetch(`${RESOURCE_BASE}/${type}/${id}`, {
37
49
  method: 'PUT',
38
50
  headers: { 'Content-Type': 'application/json' },
39
51
  body: JSON.stringify(value)
40
52
  })
41
- if (!res.ok) throw new Error(failureMessage(res.status))
53
+ if (!res.ok) throw new Error(await refusalMessage(res, failureMessage(res.status)))
42
54
  }
43
55
 
44
56
  async function deleteResource (type, id) {
@@ -653,6 +665,7 @@ function SchematicEditor ({ schematic, data, reload, onBack, onOpen, onError, re
653
665
  ${pageCircuits.map((c) => html`<option value=${c.id} key=${c.id}>${c.name}</option>`)}
654
666
  </select>
655
667
  </div>
668
+ <div class="meta schematic-legend" data-testid="schematic-legend">Solid lines are DC, dashed lines are AC. A thin line beside a wire is its second conductor.</div>
656
669
  ${liveOn && html`<${LiveBar} live=${live} error=${live.error} />`}
657
670
  ${notice && html`<div class="meta schematic-notice" data-testid="notice">${notice}</div>`}
658
671
  ${moreForm && html`
@@ -768,14 +781,32 @@ function cableLine (cable, deviceName) {
768
781
  return parts.join(', ')
769
782
  }
770
783
 
784
+ // The standard marker: a tilde for AC, the DC symbol (a solid line over a
785
+ // dashed one, IEC 60617) for DC, on a small pad so it stays readable.
786
+ function WireMarker ({ kind, at }) {
787
+ return html`
788
+ <g class="sch-wire-marker" transform="translate(${at.x} ${at.y})" data-marker=${kind}>
789
+ <rect x="-9" y="-7" width="18" height="14" rx="3" class="sch-marker-pad" />
790
+ ${kind === 'ac'
791
+ ? html`<path d="M-5 0 Q-2.5 -5 0 0 T5 0" class="sch-marker-stroke" />`
792
+ : html`
793
+ <line x1="-5" y1="-2" x2="5" y2="-2" class="sch-marker-stroke" />
794
+ <line x1="-5" y1="2" x2="5" y2="2" class="sch-marker-stroke" stroke-dasharray="2 1.5" />`}
795
+ </g>
796
+ `
797
+ }
798
+
771
799
  function WireShape ({ wire, deviceName, selected, dim, lit }) {
772
800
  const types = G.conductorTypes(wire.cable)
801
+ const style = G.wireLineStyle(wire.cable)
773
802
  const d = G.pathToD(wire.path)
803
+ const paired = types.length > 1 ? G.pathToD(G.offsetPath(wire.path, 4)) : null
774
804
  const cls = `sch-wire${selected ? ' selected' : ''}${dim ? ' dim' : ''}${lit ? ' lit' : ''}`
775
805
  return html`
776
- <g class=${cls} data-wire=${wire.id}>
777
- <path d=${d} class="sch-wire-line c-${types[0]}"><title>${cableLine(wire.cable, deviceName)}</title></path>
778
- ${types.length > 1 && html`<path d=${d} class="sch-wire-over c-${types[1]}" />`}
806
+ <g class=${cls} data-wire=${wire.id} data-current=${style.current || ''}>
807
+ <path d=${d} class="sch-wire-line c-${types[0]}" stroke-dasharray=${style.dasharray || undefined}><title>${cableLine(wire.cable, deviceName)}</title></path>
808
+ ${paired && html`<path d=${paired} class="sch-wire-pair c-${types[1]}" stroke-dasharray=${style.dasharray || undefined} />`}
809
+ ${style.marker && html`<${WireMarker} kind=${style.marker} at=${G.pathMidpoint(wire.path)} />`}
779
810
  </g>
780
811
  `
781
812
  }
package/public/style.css CHANGED
@@ -219,6 +219,16 @@ th { color: var(--text-muted-strong); font-weight: 600; background: var(--surfac
219
219
  font-size: 13px;
220
220
  }
221
221
 
222
+ .warning-banner {
223
+ background: var(--warn-bg);
224
+ border: 1px solid var(--warn);
225
+ color: var(--warn);
226
+ padding: 8px 12px;
227
+ border-radius: 8px;
228
+ margin-bottom: 10px;
229
+ font-size: 13px;
230
+ }
231
+
222
232
  button {
223
233
  font: inherit;
224
234
  font-size: 13px;
@@ -647,20 +657,26 @@ main:has(.floorplan-pair) { max-width: none; }
647
657
  .sch-terminal.c-neutral { fill: var(--wire-neutral); }
648
658
  .sch-terminal.c-earth { fill: var(--wire-earth); }
649
659
 
650
- .sch-wire-line, .sch-wire-over, .sch-stub-line { fill: none; stroke-width: 3; stroke-linejoin: round; }
660
+ .sch-wire-line, .sch-wire-pair, .sch-stub-line { fill: none; stroke-width: 3; stroke-linejoin: round; }
651
661
  .sch-wire-line.c-positive { stroke: var(--wire-positive); }
652
662
  .sch-wire-line.c-negative { stroke: var(--wire-negative); }
653
663
  .sch-wire-line.c-line { stroke: var(--wire-line); }
654
664
  .sch-wire-line.c-neutral { stroke: var(--wire-neutral); }
655
665
  .sch-wire-line.c-earth { stroke: var(--wire-earth); }
656
666
  .sch-wire-line.c-signal { stroke: var(--wire-signal); }
657
- .sch-wire-over { stroke-dasharray: 6 6; }
658
- .sch-wire-over.c-positive { stroke: var(--wire-positive); }
659
- .sch-wire-over.c-negative { stroke: var(--wire-negative); }
660
- .sch-wire-over.c-line { stroke: var(--wire-line); }
661
- .sch-wire-over.c-neutral { stroke: var(--wire-neutral); }
662
- .sch-wire-over.c-earth { stroke: var(--wire-earth); }
667
+ .sch-wire-pair { stroke-width: 1.5; }
668
+ .sch-wire-pair.c-positive { stroke: var(--wire-positive); }
669
+ .sch-wire-pair.c-negative { stroke: var(--wire-negative); }
670
+ .sch-wire-pair.c-line { stroke: var(--wire-line); }
671
+ .sch-wire-pair.c-neutral { stroke: var(--wire-neutral); }
672
+ .sch-wire-pair.c-earth { stroke: var(--wire-earth); }
673
+ .sch-wire-pair.c-signal { stroke: var(--wire-signal); }
674
+ .sch-marker-pad { fill: var(--surface); fill-opacity: 0.9; stroke: none; }
675
+ .sch-marker-stroke { fill: none; stroke: var(--text-muted-strong); stroke-width: 1.4; stroke-linecap: round; }
676
+ .sch-wire-marker { pointer-events: none; }
677
+ .schematic-legend { margin: 2px 0 6px; }
663
678
  .sch-wire.selected .sch-wire-line { stroke-width: 5; }
679
+ .sch-wire.selected .sch-wire-pair, .sch-wire.lit .sch-wire-pair { stroke-width: 2.5; }
664
680
  .sch-wire.dim { opacity: 0.2; }
665
681
  .sch-wire.lit .sch-wire-line { stroke-width: 5; }
666
682
  .sch-stub-line { stroke: var(--text-muted-strong); stroke-dasharray: 4 3; stroke-width: 2; }
package/src/current.js ADDED
@@ -0,0 +1,52 @@
1
+ 'use strict'
2
+
3
+ // AC and DC awareness (plan 0019), the parts the server needs. The browser has
4
+ // the same table and rule in public/current.js and public/deviceTypes.js;
5
+ // test/current.test.mjs and test/current.test.js keep both equal.
6
+
7
+ const CURRENTS = ['ac', 'dc']
8
+
9
+ // Device types that are plainly AC or DC, matched case-insensitively on the
10
+ // whole type name. Everything else (converters, distribution, consumers) is
11
+ // left unset.
12
+ const AC_TYPES = ['Shore power inlet', 'Generator', 'Isolation transformer']
13
+ const DC_TYPES = [
14
+ 'Solar panel', 'Alternator', 'Wind generator', 'Hydro generator', 'Fuel cell',
15
+ 'Battery', 'Battery bank', 'Lead-acid battery', 'AGM battery', 'LiFePO4 battery',
16
+ 'Starter battery', 'House battery', 'Shunt', 'Battery isolator', 'Battery switch'
17
+ ]
18
+
19
+ const TYPE_CURRENT = new Map([
20
+ ...AC_TYPES.map((t) => [t.toLowerCase(), 'ac']),
21
+ ...DC_TYPES.map((t) => [t.toLowerCase(), 'dc'])
22
+ ])
23
+
24
+ // 'ac', 'dc' or null for a device type name.
25
+ function typeCurrent (type) {
26
+ if (typeof type !== 'string') return null
27
+ return TYPE_CURRENT.get(type.trim().toLowerCase()) || null
28
+ }
29
+
30
+ const AC_CONDUCTORS = ['line', 'neutral', 'earth']
31
+ const DC_CONDUCTORS = ['positive', 'negative']
32
+
33
+ // The current a terminal list agrees on: all positive/negative is dc, all
34
+ // line/neutral/earth is ac, anything mixed or empty is null.
35
+ function terminalsCurrent (terminals) {
36
+ const list = Array.isArray(terminals) ? terminals : []
37
+ if (list.length === 0) return null
38
+ if (list.every((t) => DC_CONDUCTORS.includes(t.conductor))) return 'dc'
39
+ if (list.every((t) => AC_CONDUCTORS.includes(t.conductor))) return 'ac'
40
+ return null
41
+ }
42
+
43
+ // True when a cable's conductors include a line or neutral one and a positive
44
+ // or negative one. Earth and signal never count. Takes conductor objects or
45
+ // plain type names.
46
+ function mixesAcAndDc (conductors) {
47
+ const types = (Array.isArray(conductors) ? conductors : []).map((c) => (c && c.conductor) || c)
48
+ return types.some((t) => t === 'line' || t === 'neutral') &&
49
+ types.some((t) => t === 'positive' || t === 'negative')
50
+ }
51
+
52
+ module.exports = { CURRENTS, AC_TYPES, DC_TYPES, typeCurrent, terminalsCurrent, mixesAcAndDc }
package/src/resources.js CHANGED
@@ -20,6 +20,8 @@ function rowToResource (row) {
20
20
  if (!row) return row
21
21
  const out = {}
22
22
  for (const [k, v] of Object.entries(row)) {
23
+ // An unset current is absent, not null (plan 0019).
24
+ if (k === 'current' && (v === null || v === undefined)) continue
23
25
  out[toCamelKey(k)] = JSON_COLUMNS.has(k) && typeof v === 'string'
24
26
  ? JSON.parse(v)
25
27
  : BOOLEAN_COLUMNS.has(k) && v !== null && v !== undefined ? Boolean(v) : v
package/src/schema.sql CHANGED
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS devices (
66
66
  trip_rating_a REAL,
67
67
  nominal_voltage REAL,
68
68
  power_factor REAL,
69
+ current TEXT,
69
70
  switch_path TEXT,
70
71
  confirm_switching INTEGER NOT NULL DEFAULT 0,
71
72
  never_remote INTEGER NOT NULL DEFAULT 0,
package/src/store.js CHANGED
@@ -6,6 +6,7 @@ const crypto = require('crypto')
6
6
  const Database = require('better-sqlite3')
7
7
  const { deriveCircuits } = require('./circuits')
8
8
  const { isCriticalType } = require('./safety')
9
+ const { CURRENTS, typeCurrent, terminalsCurrent, mixesAcAndDc } = require('./current')
9
10
  const { deriveSwitchability } = require('./switchability')
10
11
 
11
12
  const SCHEMA_SQL = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8')
@@ -27,7 +28,7 @@ const TABLES = {
27
28
  table: 'devices',
28
29
  columns: [
29
30
  'id', 'name', 'type', 'role', 'icon_attachment_id', 'zone', 'rated_power_w', 'signalk_path',
30
- 'trip_rating_a', 'nominal_voltage', 'power_factor', 'switch_path', 'confirm_switching', 'never_remote',
31
+ 'trip_rating_a', 'nominal_voltage', 'power_factor', 'current', 'switch_path', 'confirm_switching', 'never_remote',
31
32
  'meter_path', 'meter_unit', 'pos_x', 'pos_y', 'pos_z', 'zone_id', 'tags', 'status', 'notes'
32
33
  ]
33
34
  },
@@ -372,6 +373,21 @@ const MIGRATIONS = [
372
373
  avg_w REAL, peak_w REAL, duty_cycle REAL, confidence TEXT, error TEXT)`)
373
374
  db.exec('CREATE INDEX IF NOT EXISTS idx_calibration_runs_device ON calibration_runs (device_id)')
374
375
  }
376
+ },
377
+ {
378
+ version: 10,
379
+ description: 'plan 0019: devices + current (ac, dc or null), filled once from the type and terminals',
380
+ apply (db) {
381
+ const hasColumn = db.prepare('PRAGMA table_info(devices)').all().some((c) => c.name === 'current')
382
+ if (!hasColumn) db.exec('ALTER TABLE devices ADD COLUMN current TEXT')
383
+ // The one-time fill: only where nothing is set, never over a mark.
384
+ const terminals = db.prepare('SELECT conductor FROM device_terminals WHERE device_id = ?')
385
+ const set = db.prepare('UPDATE devices SET current = ? WHERE id = ? AND current IS NULL')
386
+ for (const d of db.prepare('SELECT id, type FROM devices WHERE current IS NULL').all()) {
387
+ const value = typeCurrent(d.type) || terminalsCurrent(terminals.all(d.id))
388
+ if (value) set.run(value, d.id)
389
+ }
390
+ }
375
391
  }
376
392
  ]
377
393
 
@@ -698,7 +714,9 @@ class Store {
698
714
  _checkChildren (kind, children, value, owner) {
699
715
  if (value === undefined) return null
700
716
  if (!Array.isArray(value)) throw new Error(`${children.field} must be a list`)
701
- return value.map((item) => children.check(item || {}, this, owner))
717
+ const list = value.map((item) => children.check(item || {}, this, owner))
718
+ if (kind === 'wireRun' && mixesAcAndDc(list)) throw new Error('A cable cannot carry both AC and DC')
719
+ return list
702
720
  }
703
721
 
704
722
  _checkWireRunEnds (fields) {
@@ -1025,6 +1043,14 @@ class Store {
1025
1043
  if (unit !== null && !METER_UNITS.includes(unit)) throw new Error(`meter_unit must be one of: ${METER_UNITS.join(', ')}`)
1026
1044
  out = { ...out, meter_unit: unit }
1027
1045
  }
1046
+ if ('current' in out) {
1047
+ const c = out.current === '' || out.current === undefined ? null : out.current
1048
+ if (c !== null && !CURRENTS.includes(c)) throw new Error('current must be one of: ac, dc')
1049
+ out = { ...out, current: c }
1050
+ } else if (creating) {
1051
+ const c = typeCurrent(out.type)
1052
+ if (c) out = { ...out, current: c }
1053
+ }
1028
1054
  for (const key of ['confirm_switching', 'never_remote']) {
1029
1055
  if (key in out) out = { ...out, [key]: out[key] && out[key] !== '0' ? 1 : 0 }
1030
1056
  }