mqttpc 2.0.0 → 2.1.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/README.md +24 -0
- package/index.js +7 -0
- package/lib/hadiscovery.js +102 -0
- package/lib/runner.js +5 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -86,6 +86,29 @@ that changing a process away from a retained mode leaves the old message on the
|
|
|
86
86
|
| `<name>/connected` | `2` running · `0` stopped |
|
|
87
87
|
| `<name>/info` | the procs file, the process names, what is running, whether it is root |
|
|
88
88
|
|
|
89
|
+
## Home Assistant
|
|
90
|
+
|
|
91
|
+
Every process becomes **one HA device**, hanging off a bridge device for the instance:
|
|
92
|
+
|
|
93
|
+
| Entity | Platform | What it does |
|
|
94
|
+
| -------------------------- | --------------- | ------------------------------------------------- |
|
|
95
|
+
| **Running** | `binary_sensor` | on while there is a pid (`device_class: running`) |
|
|
96
|
+
| **Start** | `button` | publishes to `set/<proc>/spawn` |
|
|
97
|
+
| **Stop** | `button` | `SIGTERM` |
|
|
98
|
+
| **Kill** | `button` | `SIGKILL`, diagnostic |
|
|
99
|
+
| PID, Last exit, Last error | `sensor` | diagnostics |
|
|
100
|
+
|
|
101
|
+
The bridge device carries a **Running processes** count. Discovery is published once at start, since
|
|
102
|
+
the device set comes from the procs file; `--no-ha-discovery` turns it off and clears what was
|
|
103
|
+
announced.
|
|
104
|
+
|
|
105
|
+
The Start button presses an **empty** payload rather than HA's default `PRESS`, so a process with
|
|
106
|
+
`stdinFromSpawnPayload` does not receive the word "PRESS" on stdin.
|
|
107
|
+
|
|
108
|
+
There is deliberately no entity for `stdout` / `stderr`: an HA sensor state is capped at 255
|
|
109
|
+
characters and process output routinely exceeds that, so such an entity would spend its life logging
|
|
110
|
+
errors. Use the MQTT topics for output.
|
|
111
|
+
|
|
89
112
|
## Security
|
|
90
113
|
|
|
91
114
|
**mqttpc turns "may publish to `<name>/set/#`" into "may run these programs on this host".** That is
|
|
@@ -145,6 +168,7 @@ entry can work, since a process that is not root cannot become another user.
|
|
|
145
168
|
| `-u, --mqtt-url` | `mqtt://localhost` | broker url |
|
|
146
169
|
| `-n, --name` | `pc` | instance name = topic prefix |
|
|
147
170
|
| `--root` | `false` | `--install` runs the service as root (discouraged) |
|
|
171
|
+
| `--ha-discovery` | `true` | announce the processes to Home Assistant |
|
|
148
172
|
| `-v, --verbosity` | `info` | `error`, `warn`, `info`, `debug` |
|
|
149
173
|
|
|
150
174
|
`--help` lists the shared options too, and `--config-schema` prints the JSON Schema a management UI
|
package/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import {handle as handleInstall, ROOT_WARNING} from './lib/install.js';
|
|
|
18
18
|
import {loadProcs, needsRoot} from './lib/procs.js';
|
|
19
19
|
import {createRunner} from './lib/runner.js';
|
|
20
20
|
import {createDispatch} from './lib/dispatch.js';
|
|
21
|
+
import {discoveryModel} from './lib/hadiscovery.js';
|
|
21
22
|
|
|
22
23
|
handleInstall(config); // --install / --uninstall never reach the rest
|
|
23
24
|
|
|
@@ -44,6 +45,10 @@ const adapter = createAdapter({
|
|
|
44
45
|
running: runner ? runner.names : [],
|
|
45
46
|
root: isRoot,
|
|
46
47
|
}),
|
|
48
|
+
// one HA device per process, plus the bridge they hang off. The set is fixed once the procs
|
|
49
|
+
// file is read, so there is nothing to re-trigger it on.
|
|
50
|
+
discovery: () =>
|
|
51
|
+
discoveryModel({name: config.name, procs, jsonPayloads: config.jsonPayloads, version: pkg.version}),
|
|
47
52
|
onSet: (...args) => dispatch(...args),
|
|
48
53
|
// children of a process controller outlive nothing: stop them with it
|
|
49
54
|
onShutdown: () => runner.stopAll(),
|
|
@@ -54,8 +59,10 @@ runner = createRunner({
|
|
|
54
59
|
log: adapter.log,
|
|
55
60
|
isRoot,
|
|
56
61
|
publish: (item, value, options) => adapter.pubStatus(item, value, options),
|
|
62
|
+
onChange: () => adapter.pubStatus('bridge/running', runner.names.length),
|
|
57
63
|
});
|
|
58
64
|
dispatch = createDispatch({procs, runner, log: adapter.log});
|
|
65
|
+
adapter.pubStatus('bridge/running', 0);
|
|
59
66
|
|
|
60
67
|
if (isRoot) {
|
|
61
68
|
adapter.log.warn(ROOT_WARNING.replace('<name>', config.name));
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home Assistant discovery: one bridge device plus one device per process in the procs file,
|
|
3
|
+
* linked with `via_device`.
|
|
4
|
+
*
|
|
5
|
+
* Pure — processes in, device blocks out; the core publishes them. The set is fixed once the procs
|
|
6
|
+
* file is read, so this is built once rather than on a trigger.
|
|
7
|
+
*
|
|
8
|
+
* What a process gets is what you actually do with one: start it, stop it, see whether it is
|
|
9
|
+
* running and how it ended. Deliberately **not** stdout/stderr — an HA sensor state is capped at
|
|
10
|
+
* 255 characters and process output routinely exceeds that, so those entities would spend their
|
|
11
|
+
* life logging errors. See ROADMAP.md.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {discoveryId, entity} from 'mqtt-interfaces-core';
|
|
15
|
+
|
|
16
|
+
/** A device id may only hold these; a process name is looser (dots are legal in a topic level). */
|
|
17
|
+
const idSafe = (text) => text.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
18
|
+
|
|
19
|
+
/** `true` when the pid topic holds one, i.e. the process is running. An empty payload is not. */
|
|
20
|
+
function runningTemplate(jsonPayloads) {
|
|
21
|
+
return jsonPayloads ? "{{ 'ON' if value_json.val else 'OFF' }}" : "{{ 'ON' if value else 'OFF' }}";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} input
|
|
26
|
+
* @param {string} input.name instance name / topic prefix
|
|
27
|
+
* @param {object} input.procs the validated procs file
|
|
28
|
+
* @param {boolean} [input.jsonPayloads]
|
|
29
|
+
* @param {string} [input.version] adapter version, shown as the bridge's firmware
|
|
30
|
+
* @returns {Array<object>} device blocks for the core's discovery publisher
|
|
31
|
+
*/
|
|
32
|
+
export function discoveryModel({name, procs, jsonPayloads = true, version}) {
|
|
33
|
+
const bridgeId = discoveryId('mqttpc', name);
|
|
34
|
+
|
|
35
|
+
const blocks = Object.entries(procs).map(([proc, def]) => {
|
|
36
|
+
const id = `${bridgeId}_${idSafe(proc)}`;
|
|
37
|
+
const e = (item, uid, platform, label, more = {}) =>
|
|
38
|
+
entity({id, name, item: `${proc}/${item}`, uid, platform, label, jsonPayloads, ...more});
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
device: {
|
|
43
|
+
name: proc,
|
|
44
|
+
mf: 'mqttpc',
|
|
45
|
+
// the program is what this device actually is
|
|
46
|
+
mdl: def.path,
|
|
47
|
+
via_device: bridgeId,
|
|
48
|
+
},
|
|
49
|
+
components: {
|
|
50
|
+
running: e('pid', 'running', 'binary_sensor', 'Running', {
|
|
51
|
+
extra: {dev_cla: 'running', val_tpl: runningTemplate(jsonPayloads)},
|
|
52
|
+
}),
|
|
53
|
+
start: e('spawn', 'start', 'button', 'Start', {
|
|
54
|
+
command: true,
|
|
55
|
+
icon: 'mdi:play',
|
|
56
|
+
// the default "PRESS" would be written to stdin of a stdinFromSpawnPayload entry
|
|
57
|
+
extra: {pl_prs: ''},
|
|
58
|
+
}),
|
|
59
|
+
stop: e('signal', 'stop', 'button', 'Stop', {
|
|
60
|
+
command: true,
|
|
61
|
+
icon: 'mdi:stop',
|
|
62
|
+
extra: {pl_prs: 'SIGTERM'},
|
|
63
|
+
}),
|
|
64
|
+
kill: e('signal', 'kill', 'button', 'Kill', {
|
|
65
|
+
command: true,
|
|
66
|
+
icon: 'mdi:skull',
|
|
67
|
+
category: 'diagnostic',
|
|
68
|
+
extra: {pl_prs: 'SIGKILL'},
|
|
69
|
+
}),
|
|
70
|
+
pid: e('pid', 'pid', 'sensor', 'PID', {category: 'diagnostic', icon: 'mdi:identifier'}),
|
|
71
|
+
exit: e('exit', 'exit', 'sensor', 'Last exit', {
|
|
72
|
+
category: 'diagnostic',
|
|
73
|
+
icon: 'mdi:flag-checkered',
|
|
74
|
+
}),
|
|
75
|
+
error: e('error', 'error', 'sensor', 'Last error', {
|
|
76
|
+
category: 'diagnostic',
|
|
77
|
+
icon: 'mdi:alert-circle-outline',
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const bridge = {
|
|
84
|
+
id: bridgeId,
|
|
85
|
+
device: {mf: 'mqttpc', mdl: 'process control', ...(version && {sw: version})},
|
|
86
|
+
components: {
|
|
87
|
+
running: entity({
|
|
88
|
+
id: bridgeId,
|
|
89
|
+
name,
|
|
90
|
+
item: 'bridge/running',
|
|
91
|
+
uid: 'running',
|
|
92
|
+
platform: 'sensor',
|
|
93
|
+
label: 'Running processes',
|
|
94
|
+
category: 'diagnostic',
|
|
95
|
+
icon: 'mdi:cog-play',
|
|
96
|
+
jsonPayloads,
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return [bridge, ...blocks];
|
|
102
|
+
}
|
package/lib/runner.js
CHANGED
|
@@ -48,9 +48,10 @@ export function outputMode(proc, stream) {
|
|
|
48
48
|
* @param {object} options.log
|
|
49
49
|
* @param {(item: string, value: *, opts?: object) => void} options.publish `<name>/status/<item>`
|
|
50
50
|
* @param {boolean} [options.isRoot] whether uid/gid in an entry can be honoured
|
|
51
|
+
* @param {() => void} [options.onChange] called whenever something starts or stops
|
|
51
52
|
* @param {Function} [options.spawn] for tests
|
|
52
53
|
*/
|
|
53
|
-
export function createRunner({procs, log, publish, isRoot = false, spawn = nodeSpawn}) {
|
|
54
|
+
export function createRunner({procs, log, publish, isRoot = false, onChange = () => {}, spawn = nodeSpawn}) {
|
|
54
55
|
/** name → the running ChildProcess. Absent means not running. */
|
|
55
56
|
const running = new Map();
|
|
56
57
|
/** name → payloads waiting for their turn (`enqueueSpawns`). */
|
|
@@ -146,6 +147,7 @@ export function createRunner({procs, log, publish, isRoot = false, spawn = nodeS
|
|
|
146
147
|
statusOf(name, 'error', error.message);
|
|
147
148
|
statusOf(name, 'pid', '');
|
|
148
149
|
running.delete(name);
|
|
150
|
+
onChange();
|
|
149
151
|
});
|
|
150
152
|
|
|
151
153
|
for (const stream of ['stdout', 'stderr']) {
|
|
@@ -169,6 +171,7 @@ export function createRunner({procs, log, publish, isRoot = false, spawn = nodeS
|
|
|
169
171
|
// `code` is null when a signal killed it — 1.x tested `typeof code === null`, which is
|
|
170
172
|
// never true, so the signal never made it out
|
|
171
173
|
statusOf(name, 'exit', signal === null || signal === undefined ? code : signal);
|
|
174
|
+
onChange();
|
|
172
175
|
const queue = queues.get(name);
|
|
173
176
|
if (queue && queue.length > 0) {
|
|
174
177
|
log.info(name, 'finished; starting the next queued run');
|
|
@@ -180,6 +183,7 @@ export function createRunner({procs, log, publish, isRoot = false, spawn = nodeS
|
|
|
180
183
|
log.info(name, 'started', proc.path, 'as pid', child.pid);
|
|
181
184
|
statusOf(name, 'pid', child.pid);
|
|
182
185
|
}
|
|
186
|
+
onChange();
|
|
183
187
|
if (proc.stdinFromSpawnPayload && child.stdin) {
|
|
184
188
|
// one-shot: the payload is the whole input, so close stdin or the program waits for EOF
|
|
185
189
|
child.stdin.write(String(payload));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mqttpc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Control processes on a host via MQTT. Follows the mqtt-smarthome architecture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"serviceExtra": []
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"mqtt-interfaces-core": "^0.
|
|
57
|
+
"mqtt-interfaces-core": "^0.15.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@eslint/js": "^9",
|