feat: initial myprimal MQTT bridge service

Dynamic MySolem/MyIndygo IoT bridge with MQTT, Home Assistant
autodiscovery, and Homebridge EasyMQTT compatibility.

- Device discovery via MySolem API on startup (all modules/inputs/outputs)
- Configurable poll engine (fast/normal/slow buckets, per-input overrides)
- MQTT retained state publishing + HA autodiscovery payloads
- Unified manual command routing (linesControl vs sendManualModuleCommand)
- Runtime config via MQTT $config/set, persisted to config.json
- Expression override system (API-provided > hardcoded > passthrough)
- Minimal REST API: /health, /state, /control, /rediscover
- Docker deployment (Dockerfile + docker-compose.yml)
- 38 unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 01:05:24 +02:00
commit 941abd98d5
48 changed files with 12118 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
function makeTempPath() {
return path.join(os.tmpdir(), `myprimal-test-config-${process.pid}-${Date.now()}.json`);
}
function freshConfig(tmpPath) {
process.env.MYPRIMAL_CONFIG_PATH = tmpPath;
jest.resetModules();
return require('../src/config');
}
afterEach(() => {
delete process.env.MYPRIMAL_CONFIG_PATH;
delete process.env.POLL_FAST;
});
describe('config', () => {
test('loadConfig uses env defaults when no file', () => {
process.env.POLL_FAST = '45';
const tmp = makeTempPath();
const { loadConfig, getConfig } = freshConfig(tmp);
loadConfig();
expect(getConfig().poll.fast).toBe(45);
});
test('patchConfig deep merges and persists', () => {
const tmp = makeTempPath();
const { loadConfig, getConfig, patchConfig } = freshConfig(tmp);
loadConfig();
patchConfig({ poll: { fast: 20 } });
expect(getConfig().poll.fast).toBe(20);
expect(getConfig().poll.normal).toBeGreaterThan(0);
const saved = JSON.parse(fs.readFileSync(tmp, 'utf8'));
expect(saved.poll.fast).toBe(20);
fs.unlinkSync(tmp);
});
test('patchConfig emits change event', done => {
const tmp = makeTempPath();
const { loadConfig, patchConfig, on } = freshConfig(tmp);
loadConfig();
on('change', cfg => {
if (cfg.poll.slow === 1800) {
fs.existsSync(tmp) && fs.unlinkSync(tmp);
done();
}
});
patchConfig({ poll: { slow: 1800 } });
});
test('overrides preserved across patches', () => {
const tmp = makeTempPath();
const { loadConfig, getConfig, patchConfig } = freshConfig(tmp);
loadConfig();
patchConfig({ poll: { overrides: { 'lrps_0565c0/ph': 30 } } });
patchConfig({ poll: { fast: 10 } });
expect(getConfig().poll.overrides['lrps_0565c0/ph']).toBe(30);
expect(getConfig().poll.fast).toBe(10);
fs.unlinkSync(tmp);
});
test('loadConfig reads persisted values', () => {
const tmp = makeTempPath();
fs.writeFileSync(tmp, JSON.stringify({ poll: { fast: 99, normal: 500, slow: 1000, overrides: {} } }));
const { loadConfig, getConfig } = freshConfig(tmp);
loadConfig();
expect(getConfig().poll.fast).toBe(99);
expect(getConfig().poll.normal).toBe(500);
fs.unlinkSync(tmp);
});
});
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const { getInputType } = require('../src/input-types');
describe('getInputType', () => {
test('known types return correct descriptor', () => {
const ph = getInputType(6);
expect(ph.haComponent).toBe('sensor');
expect(ph.unit).toBe('pH');
expect(ph.pollBucket).toBe('fast');
expect(ph.expression).toBeTruthy();
const moisture = getInputType(12);
expect(moisture.haComponent).toBe('sensor');
expect(moisture.deviceClass).toBe('humidity');
expect(moisture.pollBucket).toBe('normal');
const rain = getInputType(2);
expect(rain.haComponent).toBe('binary_sensor');
expect(rain.pollBucket).toBe('slow');
});
test('unknown type returns fallback sensor with _unknown flag', () => {
const unknown = getInputType(999);
expect(unknown.haComponent).toBe('sensor');
expect(unknown._unknown).toBe(true);
expect(unknown.expression).toBeNull();
});
test('binary_sensor types have no unit', () => {
expect(getInputType(21).unit).toBeNull();
expect(getInputType(149).unit).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
'use strict';
const { getProtocol, isSensorOnly, isGateway } = require('../src/module-types');
describe('getProtocol', () => {
test('pool controller → linesControl', () => {
expect(getProtocol('lr-pc')).toBe('linesControl');
expect(getProtocol('lr-niv')).toBe('linesControl');
});
test('irrigation → manualCommand', () => {
expect(getProtocol('lr-ag')).toBe('manualCommand');
expect(getProtocol('lr-ip-eco')).toBe('manualCommand');
});
test('unknown type → linesControl with console warning', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(getProtocol('lr-unknown-xyz')).toBe('linesControl');
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
describe('isSensorOnly', () => {
test('sensor modules return true', () => {
expect(isSensorOnly('lr-ms')).toBe(true);
expect(isSensorOnly('lr-pr')).toBe(true);
expect(isSensorOnly('pool-level-sensor')).toBe(true);
expect(isSensorOnly('lr-mas')).toBe(true);
});
test('actuator modules return false', () => {
expect(isSensorOnly('lr-pc')).toBe(false);
expect(isSensorOnly('lr-ag')).toBe(false);
expect(isSensorOnly('lr-niv')).toBe(false);
});
});
describe('isGateway', () => {
test('gateway types return true', () => {
expect(isGateway('lr-mb-10')).toBe(true);
expect(isGateway('lr-mb-30')).toBe(true);
expect(isGateway('wf-mb')).toBe(true);
});
test('non-gateway types return false', () => {
expect(isGateway('lr-pc')).toBe(false);
expect(isGateway('lr-ms')).toBe(false);
});
});
+82
View File
@@ -0,0 +1,82 @@
'use strict';
const { normalizeValue, toMetricName } = require('../src/normalize');
describe('normalizeValue', () => {
test('type 6 (pH): raw / 100', () => {
expect(normalizeValue(735, 6)).toBeCloseTo(7.35);
});
test('type 133 (water temp): raw / 100', () => {
expect(normalizeValue(2450, 133)).toBeCloseTo(24.5);
});
test('type 8 (pressure): (1.5 * raw - 500) / 1000', () => {
// raw = 800 → (1.5 * 800 - 500) / 1000 = (1200 - 500) / 1000 = 0.7
expect(normalizeValue(800, 8)).toBeCloseTo(0.7);
});
test('type 5 (temperature): passthrough', () => {
expect(normalizeValue(22, 5)).toBe(22);
});
test('type 12 (moisture): passthrough', () => {
expect(normalizeValue(54, 12)).toBe(54);
});
test('type 2 (binary): passthrough', () => {
expect(normalizeValue(1, 2)).toBe(1);
expect(normalizeValue(0, 2)).toBe(0);
});
test('null raw value returns null', () => {
expect(normalizeValue(null, 6)).toBeNull();
expect(normalizeValue(undefined, 12)).toBeNull();
});
test('unknown type code: passthrough', () => {
expect(normalizeValue(42, 999)).toBe(42);
});
});
describe('normalizeValue with apiExpression', () => {
test('apiExpression overrides hardcoded type expression', () => {
// type 133 hardcoded: v/100 — override with v/10
expect(normalizeValue(190, 133, 'value/10')).toBeCloseTo(19.0);
});
test('apiExpression used when typeDef has no expression', () => {
expect(normalizeValue(250, 5, 'value/10')).toBeCloseTo(25.0);
});
test('unsafe expression rejected, falls back to type default', () => {
// type 133 hardcoded: v/100
expect(normalizeValue(2450, 133, 'require("fs")')).toBeCloseTo(24.5);
});
test('null apiExpression falls back to type expression', () => {
expect(normalizeValue(735, 6, null)).toBeCloseTo(7.35);
});
test('complex expression (pressure)', () => {
expect(normalizeValue(800, 8, '(1.5*value-500)/1000')).toBeCloseTo(0.7);
});
});
describe('toMetricName', () => {
test('lowercases and replaces spaces', () => {
expect(toMetricName('Plumbago Humidity', 0)).toBe('plumbago_humidity');
});
test('strips non-alphanumeric except underscore', () => {
expect(toMetricName('pH (pool)', 0)).toBe('ph_pool');
});
test('falls back to input_{index} when name empty', () => {
expect(toMetricName('', 3)).toBe('input_3');
expect(toMetricName(null, 1)).toBe('input_1');
});
test('handles already clean names', () => {
expect(toMetricName('temperature', 0)).toBe('temperature');
});
});
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const state = require('../src/state');
describe('state', () => {
test('set and get round-trip', () => {
state.set('input-1', { value: 24.5, unit: '°C', moduleId: 'mod-a', moduleSlug: 'lrpc_abc', metric: 'temperature', timestamp: '2026-01-01T00:00:00Z' });
const entry = state.get('input-1');
expect(entry.value).toBe(24.5);
expect(entry.unit).toBe('°C');
});
test('getAll returns all entries', () => {
state.set('input-2', { value: 7.2, unit: 'pH', moduleId: 'mod-b', moduleSlug: 'lrps_xyz', metric: 'ph', timestamp: '2026-01-01T00:00:00Z' });
const all = state.getAll();
expect(all['input-1']).toBeDefined();
expect(all['input-2']).toBeDefined();
});
test('getByModule filters by moduleId', () => {
const byMod = state.getByModule('mod-a');
expect(Object.keys(byMod)).toContain('input-1');
expect(Object.keys(byMod)).not.toContain('input-2');
});
test('inputCount returns store size', () => {
expect(state.inputCount()).toBeGreaterThanOrEqual(2);
});
test('get returns null for unknown inputId', () => {
expect(state.get('does-not-exist')).toBeNull();
});
test('update event fires on set', done => {
state.on('update', (id, entry) => {
if (id === 'input-event-test') {
expect(entry.value).toBe(999);
done();
}
});
state.set('input-event-test', { value: 999, moduleId: 'x', moduleSlug: 's', metric: 'm', timestamp: '' });
});
});