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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user