v2c-any 0.1.2
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/LICENSE +21 -0
- package/README.md +230 -0
- package/dist/adapter/adapter.js +1 -0
- package/dist/application-context.js +31 -0
- package/dist/configuration/configuration-loader.js +48 -0
- package/dist/configuration/configuration-validator.js +24 -0
- package/dist/device/shelly-pro-em/em1-status-provider.js +49 -0
- package/dist/device/shelly-pro-em/energy-information-em1-status-adapter.js +24 -0
- package/dist/device/shelly-pro-em/index.js +8 -0
- package/dist/factory/em1-status-provider-factory.js +48 -0
- package/dist/factory/executable-service-factory.js +1 -0
- package/dist/factory/mqtt-feed-executable-service-factory.js +68 -0
- package/dist/factory/mqtt-pull-executable-service-factory.js +73 -0
- package/dist/factory/mqtt-push-executable-service-factory.js +36 -0
- package/dist/factory/mqtt-service-factory.js +61 -0
- package/dist/factory/rest-service-factory.js +35 -0
- package/dist/index.js +53 -0
- package/dist/provider/adapter-provider.js +54 -0
- package/dist/provider/factory.js +1 -0
- package/dist/provider/fixed-value-provider.js +54 -0
- package/dist/provider/provider-factory.js +1 -0
- package/dist/provider/provider.js +1 -0
- package/dist/registry/registry.js +27 -0
- package/dist/schema/configuration.js +7 -0
- package/dist/schema/expectation-body.js +15 -0
- package/dist/schema/mqtt-configuration.js +62 -0
- package/dist/schema/rest-configuration.js +73 -0
- package/dist/schema/status-query.js +7 -0
- package/dist/service/executable-service.js +1 -0
- package/dist/service/mqtt-bridge-service.js +55 -0
- package/dist/service/mqtt-service.js +79 -0
- package/dist/service/no-op-executable-service.js +25 -0
- package/dist/service/pull-push-service.js +81 -0
- package/dist/service/rest-service.js +107 -0
- package/dist/template/response.js +8 -0
- package/dist/utils/callback-properties.js +1 -0
- package/dist/utils/logger.js +15 -0
- package/dist/utils/mappers.js +18 -0
- package/dist/utils/mqtt-callbacks.js +1 -0
- package/dist/utils/mqtt.js +21 -0
- package/package.json +50 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { logger } from '../utils/logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Periodic pull-then-push service.
|
|
4
|
+
* Fetches data from a `Provider` at a fixed interval and forwards it via a callback.
|
|
5
|
+
* Implements start/stop lifecycle control.
|
|
6
|
+
*
|
|
7
|
+
* @template Payload - The type of data provided and pushed
|
|
8
|
+
*/
|
|
9
|
+
export class PullPushService {
|
|
10
|
+
/**
|
|
11
|
+
* Creates a new pull/push service.
|
|
12
|
+
* @param provider - Source `Provider` that supplies data
|
|
13
|
+
* @param intervalMs - Polling interval in milliseconds
|
|
14
|
+
* @param callbackProperties - Callback container invoked with fetched data
|
|
15
|
+
*/
|
|
16
|
+
constructor(provider, intervalMs, callbackProperties) {
|
|
17
|
+
this.provider = provider;
|
|
18
|
+
this.intervalMs = intervalMs;
|
|
19
|
+
this.callbackProperties = callbackProperties;
|
|
20
|
+
this.abortController = null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Starts periodic polling and an immediate initial cycle.
|
|
24
|
+
* @returns A promise that resolves once the service starts
|
|
25
|
+
* @throws {Error} If the service is already started
|
|
26
|
+
*/
|
|
27
|
+
async start() {
|
|
28
|
+
if (this.abortController) {
|
|
29
|
+
throw new Error('Adapter already started');
|
|
30
|
+
}
|
|
31
|
+
this.abortController = new AbortController();
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
33
|
+
this.run(this.abortController.signal);
|
|
34
|
+
return Promise.resolve();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Stops periodic polling if running.
|
|
38
|
+
* @returns A promise that resolves once the service stops
|
|
39
|
+
*/
|
|
40
|
+
stop() {
|
|
41
|
+
this.abortController?.abort();
|
|
42
|
+
this.abortController = null;
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Main async loop that runs cycles until aborted.
|
|
47
|
+
* @param signal - AbortSignal to control loop cancellation
|
|
48
|
+
*/
|
|
49
|
+
async run(signal) {
|
|
50
|
+
// Create a single abort promise that resolves when signal is aborted
|
|
51
|
+
const abortPromise = new Promise((resolve) => {
|
|
52
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
53
|
+
});
|
|
54
|
+
while (!signal.aborted) {
|
|
55
|
+
try {
|
|
56
|
+
await this.cycle();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
logger.error(err, 'Error during pull-push cycle');
|
|
60
|
+
}
|
|
61
|
+
// Wait for either the interval or abort signal
|
|
62
|
+
if (!signal.aborted) {
|
|
63
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
64
|
+
setTimeout(resolve, this.intervalMs);
|
|
65
|
+
});
|
|
66
|
+
await Promise.race([timeoutPromise, abortPromise]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Single poll-and-push cycle.
|
|
72
|
+
* Retrieves data from the provider and forwards it to the callback when present.
|
|
73
|
+
*/
|
|
74
|
+
async cycle() {
|
|
75
|
+
const data = await this.provider.get();
|
|
76
|
+
if (data) {
|
|
77
|
+
logger.info({ data }, 'Pushing data');
|
|
78
|
+
await this.callbackProperties.callback(data);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { ILLEGAL_MODE_RESPONSE_TEMPLATE, UNKNOWN_ID_RESPONSE_TEMPLATE, } from '../template/response.js';
|
|
3
|
+
import { expectationBodySchema } from '../schema/expectation-body.js';
|
|
4
|
+
import { statusQuerySchema } from '../schema/status-query.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
import { FixedValueProvider } from '../provider/fixed-value-provider.js';
|
|
7
|
+
/**
|
|
8
|
+
* REST service that exposes Shelly EM1-like endpoints for energy status.
|
|
9
|
+
* Provides health checks, mock expectation updates, and status queries for grid and solar.
|
|
10
|
+
* Implements the executable service lifecycle to start and stop the HTTP server.
|
|
11
|
+
*/
|
|
12
|
+
export class RestService {
|
|
13
|
+
constructor(gridEnergyProvider, solarEnergyProvider, properties) {
|
|
14
|
+
this.gridEnergyProvider = gridEnergyProvider;
|
|
15
|
+
this.solarEnergyProvider = solarEnergyProvider;
|
|
16
|
+
this.properties = properties;
|
|
17
|
+
this.app = null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolves the energy provider by numeric identifier.
|
|
21
|
+
* 0 → grid, 1 → solar.
|
|
22
|
+
* @param id - Provider identifier (0 for grid, 1 for solar)
|
|
23
|
+
* @returns The matching provider or null if unknown
|
|
24
|
+
*/
|
|
25
|
+
getEnergyProviderById(id) {
|
|
26
|
+
let targetEmulator = null;
|
|
27
|
+
switch (id) {
|
|
28
|
+
case 0: // Grid
|
|
29
|
+
targetEmulator = this.gridEnergyProvider;
|
|
30
|
+
break;
|
|
31
|
+
case 1: // Solar
|
|
32
|
+
targetEmulator = this.solarEnergyProvider;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
return targetEmulator;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Starts the REST server and registers endpoints.
|
|
39
|
+
* - `GET /health` simple OK
|
|
40
|
+
* - `POST /expectaction` set mocked status (mock mode only)
|
|
41
|
+
* - `GET /rpc/EM1.GetStatus` fetch status for a given id
|
|
42
|
+
* @returns A promise that resolves when the server is listening
|
|
43
|
+
*/
|
|
44
|
+
async start() {
|
|
45
|
+
const app = Fastify({
|
|
46
|
+
loggerInstance: logger,
|
|
47
|
+
disableRequestLogging: true,
|
|
48
|
+
});
|
|
49
|
+
this.app = app;
|
|
50
|
+
// Simple health
|
|
51
|
+
app.get('/health', () => ({ ok: true }));
|
|
52
|
+
app.post('/expectaction', { schema: { body: expectationBodySchema } }, async (request, reply) => {
|
|
53
|
+
const body = request.body;
|
|
54
|
+
const id = body.id;
|
|
55
|
+
const targetEmulator = this.getEnergyProviderById(id);
|
|
56
|
+
if (!targetEmulator) {
|
|
57
|
+
reply.status(400);
|
|
58
|
+
return reply.send(UNKNOWN_ID_RESPONSE_TEMPLATE(id));
|
|
59
|
+
}
|
|
60
|
+
if (!(targetEmulator instanceof FixedValueProvider)) {
|
|
61
|
+
reply.status(400);
|
|
62
|
+
return reply.send(ILLEGAL_MODE_RESPONSE_TEMPLATE(id, 'mock'));
|
|
63
|
+
}
|
|
64
|
+
const mockedEmulator = targetEmulator;
|
|
65
|
+
mockedEmulator.value = body;
|
|
66
|
+
reply.status(200);
|
|
67
|
+
});
|
|
68
|
+
// Shelly EM1-like endpoint
|
|
69
|
+
app.get('/rpc/EM1.GetStatus', { schema: { querystring: statusQuerySchema } }, async (request, reply) => {
|
|
70
|
+
app.log.debug({ query: request.query }, 'Received request');
|
|
71
|
+
const { id } = request.query;
|
|
72
|
+
const energyProvider = this.getEnergyProviderById(id);
|
|
73
|
+
if (!energyProvider) {
|
|
74
|
+
reply.status(400);
|
|
75
|
+
return reply.send(UNKNOWN_ID_RESPONSE_TEMPLATE(id));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const result = await energyProvider.get();
|
|
79
|
+
if (result == undefined) {
|
|
80
|
+
reply.status(404);
|
|
81
|
+
return reply.send(UNKNOWN_ID_RESPONSE_TEMPLATE(id));
|
|
82
|
+
}
|
|
83
|
+
reply.status(200);
|
|
84
|
+
return reply.send(result);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
reply.status(500);
|
|
88
|
+
return reply.send({ code: -1, message: err.message });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
// Alias for JSON-RPC style (POST)
|
|
92
|
+
//app.post('/rpc/EM1.GetStatus', async () => app.inject({ method: 'GET', url: '/rpc/EM1.GetStatus' }).then(r => r.json()));
|
|
93
|
+
await app.listen({ port: this.properties.port, host: '0.0.0.0' });
|
|
94
|
+
logger.info({ port: this.properties.port }, 'Listening');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Stops the REST server if running.
|
|
98
|
+
* @returns A promise that resolves when the server has closed
|
|
99
|
+
*/
|
|
100
|
+
async stop() {
|
|
101
|
+
logger.info('Stopping emulator...');
|
|
102
|
+
if (this.app) {
|
|
103
|
+
await this.app.close();
|
|
104
|
+
}
|
|
105
|
+
return Promise.resolve();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const UNKNOWN_ID_RESPONSE_TEMPLATE = (id) => ({
|
|
2
|
+
code: -105,
|
|
3
|
+
message: `Argument 'id', value ${id} not found!`,
|
|
4
|
+
});
|
|
5
|
+
export const ILLEGAL_MODE_RESPONSE_TEMPLATE = (id, mode) => ({
|
|
6
|
+
code: -106,
|
|
7
|
+
message: `Emulator for id ${id} is not in ${mode} mode!`,
|
|
8
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
export const logger = pino({
|
|
3
|
+
transport: {
|
|
4
|
+
target: 'pino-pretty',
|
|
5
|
+
options: {
|
|
6
|
+
colorize: true,
|
|
7
|
+
translateTime: 'HH:MM:ss',
|
|
8
|
+
ignore: 'pid,hostname',
|
|
9
|
+
messageFormat: '{msg}',
|
|
10
|
+
singleLine: true,
|
|
11
|
+
hideObject: false,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
level: process.env.V2CA_LOG_LEVEL || 'info',
|
|
15
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function idToEnergyType(id) {
|
|
2
|
+
switch (id) {
|
|
3
|
+
case 0:
|
|
4
|
+
return 'grid';
|
|
5
|
+
case 1:
|
|
6
|
+
return 'solar';
|
|
7
|
+
default:
|
|
8
|
+
throw new Error(`Unknown energy type id: ${id}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function energyTypeToId(type) {
|
|
12
|
+
switch (type) {
|
|
13
|
+
case 'grid':
|
|
14
|
+
return 0;
|
|
15
|
+
case 'solar':
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import mqtt from 'mqtt';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Creates and configures an MQTT client with helpful logging.
|
|
5
|
+
* Subscribes to lifecycle events (connect, error, reconnect) for visibility.
|
|
6
|
+
*
|
|
7
|
+
* @param url - MQTT broker URL (e.g., mqtt://localhost:1883)
|
|
8
|
+
* @returns A promise that resolves to a connected MqttClient instance
|
|
9
|
+
* @throws {Error} If the underlying connection fails
|
|
10
|
+
*/
|
|
11
|
+
export async function createMqttClient(url) {
|
|
12
|
+
const client = await mqtt.connectAsync(url);
|
|
13
|
+
client.on('connect', () => {
|
|
14
|
+
logger.info({ url }, 'Connected to MQTT broker');
|
|
15
|
+
});
|
|
16
|
+
client.on('error', (err) => {
|
|
17
|
+
logger.error(err, 'MQTT client error');
|
|
18
|
+
});
|
|
19
|
+
client.on('reconnect', () => logger.info('Reconnecting...'));
|
|
20
|
+
return client;
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "v2c-any",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "V2C device adapter and MQTT publisher (Shelly EM1 compatible)",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"v2ca": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/tvcsantos/v2c-any#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/tvcsantos/v2c-any.git"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "tsx watch src/index.ts",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
25
|
+
"lint": "eslint src",
|
|
26
|
+
"lint:fix": "eslint src --fix",
|
|
27
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
28
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
29
|
+
"check": "npm run typecheck && npm run lint && npm run format:check"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"cosmiconfig": "^9.0.0",
|
|
33
|
+
"fastify": "^5.0.0",
|
|
34
|
+
"glob": "^13.0.0",
|
|
35
|
+
"mqtt": "^5.10.3",
|
|
36
|
+
"pino-pretty": "^13.1.3",
|
|
37
|
+
"undici": "^7.1.0",
|
|
38
|
+
"zod": "^4.2.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@eslint/js": "^9.17.0",
|
|
42
|
+
"@types/node": "^22.10.1",
|
|
43
|
+
"eslint": "^9.17.0",
|
|
44
|
+
"eslint-config-prettier": "^9.1.0",
|
|
45
|
+
"prettier": "^3.4.2",
|
|
46
|
+
"tsx": "^4.19.2",
|
|
47
|
+
"typescript": "^5.6.3",
|
|
48
|
+
"typescript-eslint": "^8.18.2"
|
|
49
|
+
}
|
|
50
|
+
}
|