Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Local Docker stack for **Fox ESS** inverters (H1, H3, KH, and OEM variants). Polls live data over Modbus TCP and sends snapshots to Check My Solar through a private tunnel.

The bridge **auto-detects** your inverter model from holding register 30000 on startup (same approach as [foxess_modbus](https://github.com/nathanmarlor/foxess_modbus)).
The bridge **auto-detects** your inverter model.

**Full guide:** [checkmy.solar/docs/using-the-app/modbus-bridge/](https://checkmy.solar/docs/using-the-app/modbus-bridge/)

Expand Down Expand Up @@ -30,6 +30,8 @@ export MODBUS_HOST='192.168.1.100' # Modbus adapter IP
export BRIDGE_HOSTNAME='bridge-....modbus.internal' # from the app
export TUNNEL_TOKEN='eyJ...' # from the app
export SITE_TIMEZONE='Europe/London' # IANA timezone for hour buckets
# export CMS_API_URL='https://checkmy.solar' # override for dev/staging
# export NOTIFY_DEBOUNCE_POLLS=2 # stable polls before work mode push
# export MODBUS_CONNECTION=aux # default; use lan for direct inverter LAN
# export INVERTER_PROFILE=h3Modern # optional override
# export BRIDGE_VERBOSE_LOG=true # log each Modbus poll and HTTP request
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ services:
MODBUS_CONNECTION: ${MODBUS_CONNECTION:-aux}
BRIDGE_HOSTNAME: ${BRIDGE_HOSTNAME:?BRIDGE_HOSTNAME is required}
SITE_TIMEZONE: ${SITE_TIMEZONE:?SITE_TIMEZONE is required}
CMS_API_URL: ${CMS_API_URL:-https://checkmy.solar}
NOTIFY_DEBOUNCE_POLLS: ${NOTIFY_DEBOUNCE_POLLS:-2}
BRIDGE_VERBOSE_LOG: ${BRIDGE_VERBOSE_LOG:-false}
networks:
cms_net:
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface BridgeConfig {
dataDir: string;
siteTimezone: string;
bridgeHostname?: string;
cmsApiUrl: string;
notifyDebouncePolls: number;
notifyTimeoutMs: number;
/** When true, log each Modbus poll and each HTTP request. */
verboseLogging: boolean;
/** Force a register profile instead of auto-detecting from the inverter model. */
Expand Down Expand Up @@ -102,6 +105,9 @@ export function loadConfig(): BridgeConfig {
dataDir: process.env.BRIDGE_DATA_DIR?.trim() || '/data',
siteTimezone: readTimezone('SITE_TIMEZONE'),
bridgeHostname: readOptional('BRIDGE_HOSTNAME'),
cmsApiUrl: readOptional('CMS_API_URL') ?? 'https://checkmy.solar',
notifyDebouncePolls: readInt('NOTIFY_DEBOUNCE_POLLS', 2),
notifyTimeoutMs: readInt('NOTIFY_TIMEOUT_MS', 5_000),
verboseLogging: readBoolean('BRIDGE_VERBOSE_LOG', false),
inverterProfile: readProfileId('INVERTER_PROFILE'),
modbusConnection: readConnectionType('MODBUS_CONNECTION', 'aux'),
Expand Down
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HourlyAggregator } from './aggregation/hourlyAggregator.js';
import { startBridgeHttpServer } from './http/server.js';
import { FoxModbusClient } from './modbus/client.js';
import { mapH1G2TodayTotalsSnapshotToFoxShape } from './modbus/h1g2TodayTotals.js';
import { WorkModeNotifier } from './notify/workModeNotifier.js';
import { RealtimeStore } from './storage/sqlite.js';
import { formatStoredTelemetryLog } from './telemetryLog.js';

Expand All @@ -16,6 +17,7 @@ function sleep(ms: number): Promise<void> {
async function runPollCycle(
modbus: FoxModbusClient,
store: RealtimeStore,
workModeNotifier: WorkModeNotifier,
aggregator: HourlyAggregator,
verboseLogging: boolean
): Promise<void> {
Expand All @@ -26,6 +28,7 @@ async function runPollCycle(
]);

store.upsert(telemetry, telemetry.sampledAt);
workModeNotifier.handleSample(telemetry);
const todayTotals = mapH1G2TodayTotalsSnapshotToFoxShape(todayTotalsSnapshot);
if (todayTotals) {
store.upsertTodayTotals(todayTotals, todayTotalsSnapshot.sampledAt);
Expand All @@ -42,6 +45,15 @@ async function main(): Promise<void> {
const config = loadConfig();
const store = new RealtimeStore(config.dataDir, { verboseLogging: config.verboseLogging });
const aggregator = new HourlyAggregator(store, config.siteTimezone);
const workModeNotifier = new WorkModeNotifier(
{
apiUrl: config.cmsApiUrl,
bridgeToken: config.bridgeToken,
debouncePolls: config.notifyDebouncePolls,
timeoutMs: config.notifyTimeoutMs,
},
store
);

let detectedInverter: ReturnType<FoxModbusClient['getDetectedInverter']> = null;

Expand Down Expand Up @@ -96,7 +108,7 @@ async function main(): Promise<void> {
backoffMs = config.pollIntervalMs;

while (true) {
await runPollCycle(modbus, store, aggregator, config.verboseLogging);
await runPollCycle(modbus, store, workModeNotifier, aggregator, config.verboseLogging);
await sleep(config.pollIntervalMs);
}
} catch (error) {
Expand Down
288 changes: 288 additions & 0 deletions src/notify/workModeNotifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import type { ModbusRealtimeTelemetry } from '@checkmysolar/modbus-telemetry';
import {
WorkModeNotifier,
type WorkModeNotifyStateStore,
postWorkModeEvent,
} from './workModeNotifier.js';

function sampleTelemetry(workMode: number): ModbusRealtimeTelemetry {
return {
loadsPower: 2.88,
pvPower: 3.5,
pv1Power: 2,
pv2Power: 1.5,
pvStringCount: 2,
pvStringPowers: { pv1Power: 2, pv2Power: 1.5 },
feedinPower: 1.2,
gridConsumptionPower: 0,
batChargePower: 0,
batDischargePower: 0.5,
SoC: 85,
ResidualEnergy: 10.5,
batVoltage: 51.2,
batCurrent: -1,
batTemperature: 28,
gridVoltage: 230,
gridCurrent: 5,
gridFrequency: 50,
meterPower2: 0.1,
ambientTemperature: 25,
deviceTemperature: 45,
runningState: 163,
isOffGrid: false,
epsPower: 0,
epsPowerR: 0,
epsVoltR: 240,
epsCurrentR: 0,
workMode,
sampledAt: '2026-07-09T11:59:30.000Z',
};
}

function createStateStore(initialWorkMode?: number): WorkModeNotifyStateStore {
let lastEmittedWorkMode = initialWorkMode;
return {
getLastEmittedWorkMode: () => lastEmittedWorkMode,
setLastEmittedWorkMode: (workMode) => {
lastEmittedWorkMode = workMode;
},
};
}

async function flushNotifications(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}

describe('WorkModeNotifier', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('requires stable polls before posting a work mode change', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));

const notifier = new WorkModeNotifier(
{
apiUrl: 'https://checkmy.solar',
bridgeToken: 'cms_bridge_test',
debouncePolls: 2,
timeoutMs: 5_000,
},
createStateStore(0)
);

notifier.handleSample(sampleTelemetry(3));
await flushNotifications();
expect(fetchMock).not.toHaveBeenCalled();

notifier.handleSample(sampleTelemetry(3));
await flushNotifications();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'https://checkmy.solar/api/bridge/events/work-mode',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer cms_bridge_test',
}),
body: JSON.stringify({
workMode: 3,
sampledAt: '2026-07-09T11:59:30.000Z',
previousWorkMode: 0,
soc: 85,
}),
})
);
});

it('resets debounce when the pending mode changes before stability', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));

const notifier = new WorkModeNotifier(
{
apiUrl: 'https://checkmy.solar',
bridgeToken: 'cms_bridge_test',
debouncePolls: 2,
timeoutMs: 5_000,
},
createStateStore(0)
);

notifier.handleSample(sampleTelemetry(3));
notifier.handleSample(sampleTelemetry(4));
await flushNotifications();
expect(fetchMock).not.toHaveBeenCalled();

notifier.handleSample(sampleTelemetry(4));
await flushNotifications();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
workMode: 4,
sampledAt: '2026-07-09T11:59:30.000Z',
previousWorkMode: 0,
soc: 85,
}),
})
);
});

it('does not fail the poll loop when the API rejects the event', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(new Response('bad request', { status: 400 }));

const notifier = new WorkModeNotifier(
{
apiUrl: 'https://checkmy.solar',
bridgeToken: 'cms_bridge_test',
debouncePolls: 1,
timeoutMs: 5_000,
},
createStateStore(0)
);

notifier.handleSample(sampleTelemetry(1));
await flushNotifications();
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('retries after a failed post when the mode is still unchanged', async () => {
const fetchMock = vi.mocked(fetch);
let resolveFirst: ((response: Response) => void) | undefined;
fetchMock
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFirst = resolve;
})
)
.mockResolvedValueOnce(new Response(null, { status: 204 }));

const stateStore = createStateStore(0);
const notifier = new WorkModeNotifier(
{
apiUrl: 'https://checkmy.solar',
bridgeToken: 'cms_bridge_test',
debouncePolls: 1,
timeoutMs: 5_000,
},
stateStore
);

notifier.handleSample(sampleTelemetry(3));
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const firstRequest = fetchMock.mock.results[0]?.value as Promise<Response>;
resolveFirst!(new Response('bad request', { status: 400 }));
await firstRequest;
await new Promise((resolve) => setTimeout(resolve, 0));
expect(stateStore.getLastEmittedWorkMode()).toBe(0);

notifier.handleSample(sampleTelemetry(3));
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(stateStore.getLastEmittedWorkMode()).toBe(3));
});

it('does not post duplicate notifications while a request is in flight', async () => {
const fetchMock = vi.mocked(fetch);
let resolveFetch: (() => void) | undefined;
fetchMock.mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveFetch = () => resolve(new Response(null, { status: 204 }));
})
);

const notifier = new WorkModeNotifier(
{
apiUrl: 'https://checkmy.solar',
bridgeToken: 'cms_bridge_test',
debouncePolls: 1,
timeoutMs: 5_000,
},
createStateStore(0)
);

notifier.handleSample(sampleTelemetry(3));
await flushNotifications();
expect(fetchMock).toHaveBeenCalledTimes(1);

notifier.handleSample(sampleTelemetry(3));
await flushNotifications();
expect(fetchMock).toHaveBeenCalledTimes(1);

resolveFetch?.();
await flushNotifications();
});

it('restores last emitted work mode from persistent state on startup', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));

const stateStore = createStateStore(0);
const notifier = new WorkModeNotifier(
{
apiUrl: 'https://checkmy.solar',
bridgeToken: 'cms_bridge_test',
debouncePolls: 1,
timeoutMs: 5_000,
},
stateStore
);

notifier.handleSample(sampleTelemetry(3));
await flushNotifications();
expect(stateStore.getLastEmittedWorkMode()).toBe(3);

const restartedNotifier = new WorkModeNotifier(
{
apiUrl: 'https://checkmy.solar',
bridgeToken: 'cms_bridge_test',
debouncePolls: 1,
timeoutMs: 5_000,
},
stateStore
);

restartedNotifier.handleSample(sampleTelemetry(3));
await flushNotifications();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

describe('postWorkModeEvent', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('accepts 204 responses', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));

await expect(
postWorkModeEvent(
{ apiUrl: 'https://checkmy.solar/', bridgeToken: 'cms_bridge_test', timeoutMs: 5_000 },
{ workMode: 2, sampledAt: '2026-07-09T11:59:30.000Z' }
)
).resolves.toBeUndefined();

expect(fetchMock).toHaveBeenCalledWith(
'https://checkmy.solar/api/bridge/events/work-mode',
expect.objectContaining({
signal: expect.any(AbortSignal),
})
);
});
});
Loading