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 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.0.0",
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.14.0"
57
+ "mqtt-interfaces-core": "^0.15.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@eslint/js": "^9",