signalk-electrical-engine-on 2.0.0
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/.editorconfig +5 -0
- package/.eslintrc.json +6 -0
- package/.github/workflows/publish.yml +30 -0
- package/.github/workflows/signalk-ci.yml +11 -0
- package/.github/workflows/test.yml +21 -0
- package/CHANGELOG.md +34 -0
- package/LICENSE +22 -0
- package/README.md +84 -0
- package/doc/appicon.png +0 -0
- package/index.js +293 -0
- package/package.json +44 -0
- package/test/plugin.test.js +246 -0
package/.editorconfig
ADDED
package/.eslintrc.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Publish Node.js Package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v3.0.2
|
|
13
|
+
- uses: actions/setup-node@v3.1.1
|
|
14
|
+
with:
|
|
15
|
+
node-version: 20
|
|
16
|
+
- run: npm install
|
|
17
|
+
- run: npm test
|
|
18
|
+
|
|
19
|
+
publish-npm:
|
|
20
|
+
needs: build
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v3.0.2
|
|
24
|
+
- uses: actions/setup-node@v3.1.1
|
|
25
|
+
with:
|
|
26
|
+
node-version: 20
|
|
27
|
+
registry-url: https://registry.npmjs.org/
|
|
28
|
+
- run: npm publish
|
|
29
|
+
env:
|
|
30
|
+
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Node CI
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
name: Run test suite
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
strategy:
|
|
10
|
+
matrix:
|
|
11
|
+
node-version: [20.x]
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v3.0.2
|
|
14
|
+
- name: Use Node.js ${{ matrix.node-version }}
|
|
15
|
+
uses: actions/setup-node@v3.1.1
|
|
16
|
+
with:
|
|
17
|
+
node-version: ${{ matrix.node-version }}
|
|
18
|
+
- run: npm install
|
|
19
|
+
- run: npm test
|
|
20
|
+
env:
|
|
21
|
+
CI: true
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [2.0.0] - 2026-09-23
|
|
4
|
+
|
|
5
|
+
Fork of [signalk-alternator-engine-on](https://github.com/meri-imperiumi/signalk-alternator-engine-on). Package renamed to `signalk-electrical-engine-on`.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Detect engine running from a configurable observe path, threshold, and polarity instead of hardcoded alternator power > 5 W
|
|
9
|
+
- OFF-biased defaults for house-bank current (`electrical.batteries.277.current`, polarity `negative`, threshold 8 A)
|
|
10
|
+
- Publish `stopped` on start until a qualifying engine-ON sample is held
|
|
11
|
+
- Optional hysteresis and hold-off to reduce chatter
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Admin UI settings: observe path, threshold, polarity (`positive` / `negative` / `absolute`), output path
|
|
15
|
+
|
|
16
|
+
## [1.2.1] - 2026-06-16
|
|
17
|
+
### Changed
|
|
18
|
+
- App icon for SK app store
|
|
19
|
+
|
|
20
|
+
## [1.2.0] - 2025-02-16
|
|
21
|
+
### Added
|
|
22
|
+
- Added support for uppercase charging modes reported by some DC-DC chargers
|
|
23
|
+
|
|
24
|
+
## [1.1.1] - 2024-05-31
|
|
25
|
+
### Fixed
|
|
26
|
+
- Prevent false state changes when receiving a `null` value
|
|
27
|
+
|
|
28
|
+
## [1.1.0] - 2024-04-08
|
|
29
|
+
### Added
|
|
30
|
+
- Added support for utilizing `chargingMode` as populated by the Victron Orion XS DC-DC charger
|
|
31
|
+
|
|
32
|
+
## [1.0.0] - 2022-02-04
|
|
33
|
+
### Added
|
|
34
|
+
- Initial release
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 meri-imperiumi
|
|
4
|
+
Copyright (c) 2026 David Place
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# signalk-electrical-engine-on
|
|
2
|
+
|
|
3
|
+
Fork of [meri-imperiumi/signalk-alternator-engine-on](https://github.com/meri-imperiumi/signalk-alternator-engine-on) (MIT). This package is renamed so it can be installed next to `@meri-imperiumi/signalk-alternator-engine-on` without colliding.
|
|
4
|
+
|
|
5
|
+
Publishes `started` | `stopped` on a Signal K propulsion path from a **configurable** electrical observe path, threshold, and polarity. Downstream tools (for example Trip Report) can prefer `propulsion.*.state` instead of inferring engine-on from raw electrical data.
|
|
6
|
+
|
|
7
|
+
## Warning
|
|
8
|
+
|
|
9
|
+
House-bank sensing can **false-positive** on solar, shore power, DC-DC charging, or other large loads that are not the engine. Validate on the vessel before relying on this for trip reports or engine hours. An alternator SmartShunt (the upstream plugin’s model) is more specific when you have one.
|
|
10
|
+
|
|
11
|
+
## Defaults (OFF-biased)
|
|
12
|
+
|
|
13
|
+
Until engine-ON samples are locked in, defaults are deliberately conservative so an idle house bank does not look like a running engine.
|
|
14
|
+
|
|
15
|
+
Tequila Mockingbird’s observed OFF baseline on `electrical.batteries.277` is about **−1.4 A** (~−18 W). Defaults:
|
|
16
|
+
|
|
17
|
+
| Setting | Default | Why |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| Observe path | `electrical.batteries.277.current` | House battery current (A), not alternator power |
|
|
20
|
+
| Polarity | `negative` | Charging/in from the engine is negative current on this shunt |
|
|
21
|
+
| Threshold | `8` (amperes) | Started only when value **< −8 A**. −1.4 A is clearly `stopped` |
|
|
22
|
+
| Output path | `propulsion.main.state` | Values: `started` \| `stopped` |
|
|
23
|
+
| Hysteresis | `1` | Must recross 7 A equivalent before leaving `started` |
|
|
24
|
+
| Hold-off | `1500` ms | Ignores brief spikes |
|
|
25
|
+
|
|
26
|
+
The plugin publishes `stopped` as soon as it starts, then updates from the observe path.
|
|
27
|
+
|
|
28
|
+
### Tequila Mockingbird example config
|
|
29
|
+
|
|
30
|
+
ON-sample defaults are **not locked yet** (replace `threshold` after measuring engine-ON current):
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"observe_path": "electrical.batteries.277.current",
|
|
35
|
+
"polarity": "negative",
|
|
36
|
+
"threshold": 8,
|
|
37
|
+
"output_path": "propulsion.main.state",
|
|
38
|
+
"hysteresis": 1,
|
|
39
|
+
"holdoff_ms": 1500
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Settings (Admin UI)
|
|
44
|
+
|
|
45
|
+
Plugin Config settings apply on save (plugin stop/start). You do not need to reinstall.
|
|
46
|
+
|
|
47
|
+
- **Observe path** — Signal K path for current or power
|
|
48
|
+
- **Threshold** — number, same units as the observe path
|
|
49
|
+
- **Polarity**
|
|
50
|
+
- `positive`: value > threshold ⇒ `started` (alternator power in watts)
|
|
51
|
+
- `negative`: value < −threshold ⇒ `started` (house-bank current into the battery)
|
|
52
|
+
- `absolute`: \|value\| > threshold ⇒ `started`
|
|
53
|
+
- **Output path** — default `propulsion.main.state`
|
|
54
|
+
- **Hysteresis** / **Hold-off** — optional chatter control
|
|
55
|
+
- **Use charging mode** — optional Victron Orion XS `chargingMode` override (unchanged from upstream)
|
|
56
|
+
|
|
57
|
+
## Install (Signal K on a Raspberry Pi)
|
|
58
|
+
|
|
59
|
+
Install from git into the Signal K server prefix (`~/.signalk` on a typical Pi image). Prefer this over copying files by hand:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm install --prefix ~/.signalk git+https://github.com/dgplace/signalk-electrical-engine-on.git
|
|
63
|
+
sudo systemctl restart signalk.service
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Equivalent, from the Signal K data directory:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cd ~/.signalk
|
|
70
|
+
npm install git+https://github.com/dgplace/signalk-electrical-engine-on.git
|
|
71
|
+
sudo systemctl restart signalk.service
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
In Admin UI: **Appstore** → install from that git URL if your server build supports git installs, then **Server → Plugin Config → Electrical Engine Status Detector**. Enable the plugin and set observe path / threshold / polarity.
|
|
75
|
+
|
|
76
|
+
npm package name: `signalk-electrical-engine-on` (plugin id `signalk-electrical-engine-on`). Display name: Electrical Engine Status Detector.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT. Copyright (c) 2022 meri-imperiumi; copyright (c) 2026 David Place. See [LICENSE](LICENSE).
|
|
81
|
+
|
|
82
|
+
## Changes
|
|
83
|
+
|
|
84
|
+
See [Changelog](CHANGELOG.md).
|
package/doc/appicon.png
ADDED
|
Binary file
|
package/index.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
// OFF-biased for Tequila Mockingbird house bank (electrical.batteries.277):
|
|
3
|
+
// idle current is about -1.4 A. Threshold 8 A keeps that clearly stopped
|
|
4
|
+
// until engine-ON samples are used to tighten the setting.
|
|
5
|
+
observe_path: 'electrical.batteries.277.current',
|
|
6
|
+
threshold: 8,
|
|
7
|
+
polarity: 'negative',
|
|
8
|
+
output_path: 'propulsion.main.state',
|
|
9
|
+
hysteresis: 1,
|
|
10
|
+
holdoff_ms: 1500,
|
|
11
|
+
use_chargingmode: false,
|
|
12
|
+
chargingmodePath: 'electrical.chargers.alternator.chargingMode',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const POLARITIES = ['positive', 'negative', 'absolute'];
|
|
16
|
+
|
|
17
|
+
function finiteNumber(value, fallback) {
|
|
18
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
22
|
+
const parsed = Number(value);
|
|
23
|
+
if (Number.isFinite(parsed)) {
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveOptions(options = {}) {
|
|
31
|
+
const polarity = POLARITIES.includes(options.polarity)
|
|
32
|
+
? options.polarity
|
|
33
|
+
: DEFAULTS.polarity;
|
|
34
|
+
const threshold = Math.abs(finiteNumber(options.threshold, DEFAULTS.threshold));
|
|
35
|
+
let hysteresis = finiteNumber(options.hysteresis, DEFAULTS.hysteresis);
|
|
36
|
+
if (hysteresis < 0) {
|
|
37
|
+
hysteresis = 0;
|
|
38
|
+
}
|
|
39
|
+
hysteresis = Math.min(hysteresis, threshold);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
observe_path: options.observe_path || DEFAULTS.observe_path,
|
|
43
|
+
threshold,
|
|
44
|
+
polarity,
|
|
45
|
+
output_path: options.output_path || options.engine_path || DEFAULTS.output_path,
|
|
46
|
+
hysteresis,
|
|
47
|
+
holdoff_ms: Math.max(0, finiteNumber(options.holdoff_ms, DEFAULTS.holdoff_ms)),
|
|
48
|
+
use_chargingmode: Boolean(options.use_chargingmode),
|
|
49
|
+
chargingmodePath: options.chargingmodePath || DEFAULTS.chargingmodePath,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toFiniteNumber(value) {
|
|
54
|
+
return finiteNumber(value, null);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Returns whether the engine should be considered started given the latest
|
|
58
|
+
// sample. Invalid values leave the previous state unchanged (OFF if none).
|
|
59
|
+
function isEngineStarted(value, options, currentlyStarted) {
|
|
60
|
+
const sample = toFiniteNumber(value);
|
|
61
|
+
if (sample === null) {
|
|
62
|
+
return currentlyStarted === true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { threshold, polarity, hysteresis } = options;
|
|
66
|
+
const stopThreshold = threshold - hysteresis;
|
|
67
|
+
const started = currentlyStarted === true;
|
|
68
|
+
|
|
69
|
+
if (polarity === 'negative') {
|
|
70
|
+
if (started) {
|
|
71
|
+
return sample < -stopThreshold;
|
|
72
|
+
}
|
|
73
|
+
return sample < -threshold;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (polarity === 'absolute') {
|
|
77
|
+
const magnitude = Math.abs(sample);
|
|
78
|
+
if (started) {
|
|
79
|
+
return magnitude > stopThreshold;
|
|
80
|
+
}
|
|
81
|
+
return magnitude > threshold;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (started) {
|
|
85
|
+
return sample > stopThreshold;
|
|
86
|
+
}
|
|
87
|
+
return sample > threshold;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function chargingModeStarted(value) {
|
|
91
|
+
if (value == null) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (value === 'off' || value === 'OFF') {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = (app) => {
|
|
101
|
+
const plugin = {};
|
|
102
|
+
let unsubscribes = [];
|
|
103
|
+
let holdoffTimer = null;
|
|
104
|
+
|
|
105
|
+
plugin.id = 'signalk-electrical-engine-on';
|
|
106
|
+
plugin.name = 'Electrical engine status detector';
|
|
107
|
+
plugin.description = 'Publishes propulsion started/stopped from electrical current or power';
|
|
108
|
+
const setStatus = app.setPluginStatus || app.setProviderStatus;
|
|
109
|
+
|
|
110
|
+
plugin.start = (options) => {
|
|
111
|
+
const opts = resolveOptions(options);
|
|
112
|
+
const subscribePaths = [
|
|
113
|
+
{
|
|
114
|
+
path: opts.observe_path,
|
|
115
|
+
period: 100,
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
if (opts.use_chargingmode) {
|
|
119
|
+
subscribePaths.push({
|
|
120
|
+
path: opts.chargingmodePath,
|
|
121
|
+
period: 100,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const subscription = {
|
|
126
|
+
context: 'vessels.self',
|
|
127
|
+
subscribe: subscribePaths,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
let publishedState = 'stopped';
|
|
131
|
+
let pendingState = null;
|
|
132
|
+
|
|
133
|
+
function clearHoldoff() {
|
|
134
|
+
if (holdoffTimer) {
|
|
135
|
+
clearTimeout(holdoffTimer);
|
|
136
|
+
holdoffTimer = null;
|
|
137
|
+
}
|
|
138
|
+
pendingState = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function setState(state) {
|
|
142
|
+
publishedState = state;
|
|
143
|
+
app.handleMessage(plugin.id, {
|
|
144
|
+
context: `vessels.${app.selfId}`,
|
|
145
|
+
updates: [
|
|
146
|
+
{
|
|
147
|
+
source: {
|
|
148
|
+
label: plugin.id,
|
|
149
|
+
},
|
|
150
|
+
timestamp: (new Date().toISOString()),
|
|
151
|
+
values: [
|
|
152
|
+
{
|
|
153
|
+
path: opts.output_path,
|
|
154
|
+
value: state,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
if (setStatus) {
|
|
161
|
+
setStatus(`Detected engine state: ${state}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function consider(state) {
|
|
166
|
+
if (state === publishedState) {
|
|
167
|
+
clearHoldoff();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (state === pendingState) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
clearHoldoff();
|
|
174
|
+
pendingState = state;
|
|
175
|
+
if (opts.holdoff_ms <= 0) {
|
|
176
|
+
pendingState = null;
|
|
177
|
+
setState(state);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
holdoffTimer = setTimeout(() => {
|
|
181
|
+
holdoffTimer = null;
|
|
182
|
+
pendingState = null;
|
|
183
|
+
setState(state);
|
|
184
|
+
}, opts.holdoff_ms);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
app.subscriptionmanager.subscribe(
|
|
188
|
+
subscription,
|
|
189
|
+
unsubscribes,
|
|
190
|
+
(subscriptionError) => {
|
|
191
|
+
app.error(`Error:${subscriptionError}`);
|
|
192
|
+
},
|
|
193
|
+
(delta) => {
|
|
194
|
+
if (!delta.updates) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
delta.updates.forEach((u) => {
|
|
198
|
+
if (!u.values) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
u.values.forEach((v) => {
|
|
202
|
+
if (opts.use_chargingmode && v.path === opts.chargingmodePath) {
|
|
203
|
+
const started = chargingModeStarted(v.value);
|
|
204
|
+
if (started == null) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
consider(started ? 'started' : 'stopped');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!opts.use_chargingmode && v.path === opts.observe_path) {
|
|
211
|
+
const currentlyStarted = publishedState === 'started';
|
|
212
|
+
const started = isEngineStarted(v.value, opts, currentlyStarted);
|
|
213
|
+
consider(started ? 'started' : 'stopped');
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// OFF-biased until a qualifying engine-ON sample is held long enough.
|
|
221
|
+
setState('stopped');
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
plugin.stop = () => {
|
|
225
|
+
if (holdoffTimer) {
|
|
226
|
+
clearTimeout(holdoffTimer);
|
|
227
|
+
holdoffTimer = null;
|
|
228
|
+
}
|
|
229
|
+
unsubscribes.forEach((f) => f());
|
|
230
|
+
unsubscribes = [];
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
plugin.schema = {
|
|
234
|
+
type: 'object',
|
|
235
|
+
properties: {
|
|
236
|
+
observe_path: {
|
|
237
|
+
type: 'string',
|
|
238
|
+
default: DEFAULTS.observe_path,
|
|
239
|
+
title: 'Observe path',
|
|
240
|
+
description: 'Signal K path for current or power used to detect engine-on',
|
|
241
|
+
},
|
|
242
|
+
threshold: {
|
|
243
|
+
type: 'number',
|
|
244
|
+
default: DEFAULTS.threshold,
|
|
245
|
+
title: 'Threshold',
|
|
246
|
+
description: 'Crossed according to polarity to publish started',
|
|
247
|
+
},
|
|
248
|
+
polarity: {
|
|
249
|
+
type: 'string',
|
|
250
|
+
enum: POLARITIES,
|
|
251
|
+
default: DEFAULTS.polarity,
|
|
252
|
+
title: 'Polarity',
|
|
253
|
+
description: 'positive: value > threshold => started; '
|
|
254
|
+
+ 'negative: value < -threshold => started; '
|
|
255
|
+
+ 'absolute: |value| > threshold => started',
|
|
256
|
+
},
|
|
257
|
+
output_path: {
|
|
258
|
+
type: 'string',
|
|
259
|
+
default: DEFAULTS.output_path,
|
|
260
|
+
title: 'Output path',
|
|
261
|
+
description: 'Signal K path to publish. Values are started or stopped.',
|
|
262
|
+
},
|
|
263
|
+
hysteresis: {
|
|
264
|
+
type: 'number',
|
|
265
|
+
default: DEFAULTS.hysteresis,
|
|
266
|
+
title: 'Hysteresis',
|
|
267
|
+
description: 'Band (same units as threshold) recrossed before leaving started',
|
|
268
|
+
},
|
|
269
|
+
holdoff_ms: {
|
|
270
|
+
type: 'number',
|
|
271
|
+
default: DEFAULTS.holdoff_ms,
|
|
272
|
+
title: 'Hold-off (ms)',
|
|
273
|
+
description: 'Milliseconds a new state must persist before it is published',
|
|
274
|
+
},
|
|
275
|
+
use_chargingmode: {
|
|
276
|
+
type: 'boolean',
|
|
277
|
+
default: DEFAULTS.use_chargingmode,
|
|
278
|
+
title: 'Use charging mode instead of observe path (Orion XS)',
|
|
279
|
+
},
|
|
280
|
+
chargingmodePath: {
|
|
281
|
+
type: 'string',
|
|
282
|
+
default: DEFAULTS.chargingmodePath,
|
|
283
|
+
title: 'Charging mode path (when charging mode is enabled)',
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return plugin;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
module.exports.DEFAULTS = DEFAULTS;
|
|
292
|
+
module.exports.resolveOptions = resolveOptions;
|
|
293
|
+
module.exports.isEngineStarted = isEngineStarted;
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signalk-electrical-engine-on",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Signal K plugin that publishes propulsion started/stopped from a configurable electrical current or power path",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"pretest": "eslint .",
|
|
8
|
+
"test": "node --test test/*.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git://github.com/dgplace/signalk-electrical-engine-on.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"signalk-node-server-plugin",
|
|
16
|
+
"signalk-category-utility"
|
|
17
|
+
],
|
|
18
|
+
"author": "Henri Bergius <henri.bergius@iki.fi>",
|
|
19
|
+
"contributors": [
|
|
20
|
+
"David Place"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/dgplace/signalk-electrical-engine-on/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/dgplace/signalk-electrical-engine-on#readme",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"eslint": "^8.14.0",
|
|
31
|
+
"eslint-config-airbnb-base": "^15.0.0",
|
|
32
|
+
"eslint-plugin-import": "^2.18.2"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"signalk": {
|
|
38
|
+
"appIcon": "./doc/appicon.png",
|
|
39
|
+
"displayName": "Electrical Engine Status Detector",
|
|
40
|
+
"recommends": [
|
|
41
|
+
"@meri-imperiumi/signalk-autostate"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
const { describe, it } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const pluginFactory = require('../index');
|
|
5
|
+
|
|
6
|
+
const { DEFAULTS, isEngineStarted, resolveOptions } = pluginFactory;
|
|
7
|
+
|
|
8
|
+
function publishedState(message) {
|
|
9
|
+
return message.delta.updates[0].values[0].value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function publishedPath(message) {
|
|
13
|
+
return message.delta.updates[0].values[0].path;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createApp() {
|
|
17
|
+
let onDelta = () => {};
|
|
18
|
+
let lastSubscription;
|
|
19
|
+
const messages = [];
|
|
20
|
+
const app = {
|
|
21
|
+
selfId: 'urn:mrn:signalk:uuid:test',
|
|
22
|
+
debug: () => {},
|
|
23
|
+
error: () => {},
|
|
24
|
+
setPluginStatus: () => {},
|
|
25
|
+
handleMessage: (id, delta) => {
|
|
26
|
+
messages.push({ id, delta });
|
|
27
|
+
},
|
|
28
|
+
subscriptionmanager: {
|
|
29
|
+
subscribe: (subscription, unsubscribes, _onError, cb) => {
|
|
30
|
+
lastSubscription = subscription;
|
|
31
|
+
onDelta = cb;
|
|
32
|
+
unsubscribes.push(() => {
|
|
33
|
+
onDelta = () => {};
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
app,
|
|
40
|
+
messages,
|
|
41
|
+
getSubscription: () => lastSubscription,
|
|
42
|
+
send(path, value) {
|
|
43
|
+
onDelta({
|
|
44
|
+
updates: [{ values: [{ path, value }] }],
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
sendEmpty() {
|
|
48
|
+
onDelta({});
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('plugin interface', () => {
|
|
54
|
+
const harness = createApp();
|
|
55
|
+
const plugin = pluginFactory(harness.app);
|
|
56
|
+
|
|
57
|
+
it('has required interface and renamed id', () => {
|
|
58
|
+
assert.equal(typeof plugin.start, 'function');
|
|
59
|
+
assert.equal(typeof plugin.stop, 'function');
|
|
60
|
+
assert.equal(plugin.id, 'signalk-electrical-engine-on');
|
|
61
|
+
assert.equal(plugin.name, 'Electrical engine status detector');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('exposes schema for observe path, threshold, polarity, and output path', () => {
|
|
65
|
+
const { properties } = plugin.schema;
|
|
66
|
+
assert.equal(properties.observe_path.default, 'electrical.batteries.277.current');
|
|
67
|
+
assert.equal(properties.threshold.default, 8);
|
|
68
|
+
assert.deepEqual(properties.polarity.enum, ['positive', 'negative', 'absolute']);
|
|
69
|
+
assert.equal(properties.polarity.default, 'negative');
|
|
70
|
+
assert.equal(properties.output_path.default, 'propulsion.main.state');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('starts and stops without error', () => {
|
|
74
|
+
plugin.start({}, () => {});
|
|
75
|
+
plugin.stop();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('isEngineStarted polarity', () => {
|
|
80
|
+
it('positive: value > threshold ⇒ started', () => {
|
|
81
|
+
const opts = resolveOptions({
|
|
82
|
+
polarity: 'positive',
|
|
83
|
+
threshold: 5,
|
|
84
|
+
hysteresis: 0,
|
|
85
|
+
});
|
|
86
|
+
assert.equal(isEngineStarted(5.1, opts, false), true);
|
|
87
|
+
assert.equal(isEngineStarted(5, opts, false), false);
|
|
88
|
+
assert.equal(isEngineStarted(4, opts, false), false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('negative: value < −threshold ⇒ started', () => {
|
|
92
|
+
const opts = resolveOptions({
|
|
93
|
+
polarity: 'negative',
|
|
94
|
+
threshold: 8,
|
|
95
|
+
hysteresis: 0,
|
|
96
|
+
});
|
|
97
|
+
assert.equal(isEngineStarted(-8.1, opts, false), true);
|
|
98
|
+
assert.equal(isEngineStarted(-8, opts, false), false);
|
|
99
|
+
assert.equal(isEngineStarted(8.1, opts, false), false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('absolute: |value| > threshold ⇒ started', () => {
|
|
103
|
+
const opts = resolveOptions({
|
|
104
|
+
polarity: 'absolute',
|
|
105
|
+
threshold: 5,
|
|
106
|
+
hysteresis: 0,
|
|
107
|
+
});
|
|
108
|
+
assert.equal(isEngineStarted(5.1, opts, false), true);
|
|
109
|
+
assert.equal(isEngineStarted(-5.1, opts, false), true);
|
|
110
|
+
assert.equal(isEngineStarted(1, opts, false), false);
|
|
111
|
+
assert.equal(isEngineStarted(-1, opts, false), false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('OFF baseline −1.4 A is stopped with default negative / 8 A threshold', () => {
|
|
115
|
+
const opts = resolveOptions({});
|
|
116
|
+
assert.equal(opts.observe_path, 'electrical.batteries.277.current');
|
|
117
|
+
assert.equal(opts.polarity, 'negative');
|
|
118
|
+
assert.equal(opts.threshold, 8);
|
|
119
|
+
assert.equal(isEngineStarted(-1.4, opts, false), false);
|
|
120
|
+
assert.equal(isEngineStarted(-1.4, opts, true), false);
|
|
121
|
+
assert.equal(isEngineStarted(-18 / 12.8, opts, false), false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not start on invalid samples', () => {
|
|
125
|
+
const opts = resolveOptions({});
|
|
126
|
+
assert.equal(isEngineStarted(null, opts, false), false);
|
|
127
|
+
assert.equal(isEngineStarted(undefined, opts, false), false);
|
|
128
|
+
assert.equal(isEngineStarted('not-a-number', opts, false), false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('hysteresis keeps started until the stop band is recrossed', () => {
|
|
132
|
+
const opts = resolveOptions({
|
|
133
|
+
polarity: 'negative',
|
|
134
|
+
threshold: 8,
|
|
135
|
+
hysteresis: 1,
|
|
136
|
+
});
|
|
137
|
+
assert.equal(isEngineStarted(-8.5, opts, false), true);
|
|
138
|
+
assert.equal(isEngineStarted(-7.5, opts, true), true);
|
|
139
|
+
assert.equal(isEngineStarted(-6.9, opts, true), false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('subscribe → publish', () => {
|
|
144
|
+
it('publishes stopped on start (OFF-biased) to the output path', () => {
|
|
145
|
+
const harness = createApp();
|
|
146
|
+
const plugin = pluginFactory(harness.app);
|
|
147
|
+
plugin.start({ holdoff_ms: 0 });
|
|
148
|
+
assert.equal(harness.messages.length, 1);
|
|
149
|
+
assert.equal(harness.messages[0].id, 'signalk-electrical-engine-on');
|
|
150
|
+
assert.equal(publishedState(harness.messages[0]), 'stopped');
|
|
151
|
+
assert.equal(publishedPath(harness.messages[0]), 'propulsion.main.state');
|
|
152
|
+
const sub = harness.getSubscription();
|
|
153
|
+
assert.equal(sub.context, 'vessels.self');
|
|
154
|
+
assert.equal(sub.subscribe[0].path, DEFAULTS.observe_path);
|
|
155
|
+
plugin.stop();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('does not start the engine on TM OFF baseline current', () => {
|
|
159
|
+
const harness = createApp();
|
|
160
|
+
const plugin = pluginFactory(harness.app);
|
|
161
|
+
plugin.start({ holdoff_ms: 0 });
|
|
162
|
+
harness.send('electrical.batteries.277.current', -1.4);
|
|
163
|
+
assert.equal(publishedState(harness.messages[harness.messages.length - 1]), 'stopped');
|
|
164
|
+
plugin.stop();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('publishes started for negative polarity below −threshold', () => {
|
|
168
|
+
const harness = createApp();
|
|
169
|
+
const plugin = pluginFactory(harness.app);
|
|
170
|
+
plugin.start({ holdoff_ms: 0 });
|
|
171
|
+
harness.send('electrical.batteries.277.current', -20);
|
|
172
|
+
assert.equal(publishedState(harness.messages[harness.messages.length - 1]), 'started');
|
|
173
|
+
plugin.stop();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('publishes started for positive polarity above threshold', () => {
|
|
177
|
+
const harness = createApp();
|
|
178
|
+
const plugin = pluginFactory(harness.app);
|
|
179
|
+
plugin.start({
|
|
180
|
+
observe_path: 'electrical.chargers.alternator.power',
|
|
181
|
+
polarity: 'positive',
|
|
182
|
+
threshold: 5,
|
|
183
|
+
hysteresis: 0,
|
|
184
|
+
holdoff_ms: 0,
|
|
185
|
+
});
|
|
186
|
+
harness.send('electrical.chargers.alternator.power', 6);
|
|
187
|
+
assert.equal(publishedState(harness.messages[harness.messages.length - 1]), 'started');
|
|
188
|
+
harness.send('electrical.chargers.alternator.power', 4);
|
|
189
|
+
assert.equal(publishedState(harness.messages[harness.messages.length - 1]), 'stopped');
|
|
190
|
+
plugin.stop();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('publishes started for absolute polarity on either sign', () => {
|
|
194
|
+
const harness = createApp();
|
|
195
|
+
const plugin = pluginFactory(harness.app);
|
|
196
|
+
plugin.start({
|
|
197
|
+
polarity: 'absolute',
|
|
198
|
+
threshold: 5,
|
|
199
|
+
hysteresis: 0,
|
|
200
|
+
holdoff_ms: 0,
|
|
201
|
+
output_path: 'propulsion.port.state',
|
|
202
|
+
});
|
|
203
|
+
assert.equal(publishedPath(harness.messages[0]), 'propulsion.port.state');
|
|
204
|
+
harness.send('electrical.batteries.277.current', -6);
|
|
205
|
+
assert.equal(publishedState(harness.messages[harness.messages.length - 1]), 'started');
|
|
206
|
+
harness.send('electrical.batteries.277.current', 6);
|
|
207
|
+
assert.equal(publishedState(harness.messages[harness.messages.length - 1]), 'started');
|
|
208
|
+
harness.send('electrical.batteries.277.current', 1);
|
|
209
|
+
assert.equal(publishedState(harness.messages[harness.messages.length - 1]), 'stopped');
|
|
210
|
+
plugin.stop();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('ignores other paths and empty deltas', () => {
|
|
214
|
+
const harness = createApp();
|
|
215
|
+
const plugin = pluginFactory(harness.app);
|
|
216
|
+
plugin.start({ holdoff_ms: 0 });
|
|
217
|
+
harness.sendEmpty();
|
|
218
|
+
harness.send('navigation.speedThroughWater', 3);
|
|
219
|
+
assert.equal(harness.messages.length, 1);
|
|
220
|
+
assert.equal(publishedState(harness.messages[0]), 'stopped');
|
|
221
|
+
plugin.stop();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('hold-off delays started until the sample persists', () => {
|
|
225
|
+
const harness = createApp();
|
|
226
|
+
const plugin = pluginFactory(harness.app);
|
|
227
|
+
plugin.start({ holdoff_ms: 50, hysteresis: 0 });
|
|
228
|
+
harness.send('electrical.batteries.277.current', -20);
|
|
229
|
+
assert.equal(publishedState(harness.messages[harness.messages.length - 1]), 'stopped');
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
setTimeout(() => {
|
|
232
|
+
try {
|
|
233
|
+
assert.equal(
|
|
234
|
+
publishedState(harness.messages[harness.messages.length - 1]),
|
|
235
|
+
'started',
|
|
236
|
+
);
|
|
237
|
+
plugin.stop();
|
|
238
|
+
resolve();
|
|
239
|
+
} catch (err) {
|
|
240
|
+
plugin.stop();
|
|
241
|
+
reject(err);
|
|
242
|
+
}
|
|
243
|
+
}, 80);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|