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 +7 -0
- package/package.json +1 -1
- package/public/app.js +72 -9
- package/public/cablePopover.js +3 -1
- package/public/current.js +113 -0
- package/public/deviceTypes.js +20 -0
- package/public/floorplanView.js +17 -4
- package/public/import.js +41 -17
- package/public/quickCable.js +7 -4
- package/public/schematic.js +46 -0
- package/public/schematicView.js +35 -4
- package/public/style.css +23 -7
- package/src/current.js +52 -0
- package/src/resources.js +2 -0
- package/src/schema.sql +1 -0
- package/src/store.js +28 -2
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
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=""
|
|
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) =>
|
|
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) =>
|
|
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=${() =>
|
|
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=${
|
|
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
|
-
|
|
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) => {
|
package/public/cablePopover.js
CHANGED
|
@@ -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
|
+
}
|
package/public/deviceTypes.js
CHANGED
|
@@ -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
|
+
}
|
package/public/floorplanView.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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:
|
|
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>
|
package/public/quickCable.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
12
|
-
|
|
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 =
|
|
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,
|
package/public/schematic.js
CHANGED
|
@@ -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.
|
package/public/schematicView.js
CHANGED
|
@@ -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
|
-
${
|
|
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-
|
|
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-
|
|
658
|
-
.sch-wire-
|
|
659
|
-
.sch-wire-
|
|
660
|
-
.sch-wire-
|
|
661
|
-
.sch-wire-
|
|
662
|
-
.sch-wire-
|
|
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
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
|
-
|
|
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
|
}
|