v2c-any 0.1.9 → 0.2.1
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 +7 -3
- package/dist/device/shelly-pro-em/em1-status-provider.js +22 -4
- package/dist/factory/decorator-provider-factory.js +40 -0
- package/dist/factory/em1-status-provider-factory.js +8 -7
- package/dist/factory/mqtt-feed-executable-service-factory.js +0 -2
- package/dist/factory/mqtt-pull-executable-service-factory.js +12 -11
- package/dist/factory/mqtt-push-executable-service-factory.js +3 -2
- package/dist/factory/mqtt-service-factory.js +0 -2
- package/dist/factory/rest-service-factory.js +0 -2
- package/dist/provider/asymmetric-emea-provider.js +104 -0
- package/dist/provider/interpolator.js +54 -0
- package/dist/provider/retryable-provider.js +37 -0
- package/dist/schema/mqtt-configuration.js +2 -1
- package/dist/schema/rest-configuration.js +6 -2
- package/dist/service/rest-service.js +0 -2
- package/dist/utils/interpolator.js +13 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ It adapts **any input** into the protocol and format expected by a V2C wallbox
|
|
|
40
40
|
|
|
41
41
|
Or in practical terms:
|
|
42
42
|
|
|
43
|
-
```
|
|
43
|
+
```text
|
|
44
44
|
[Any Meter | MQTT | API | Simulator]
|
|
45
45
|
│
|
|
46
46
|
▼
|
|
@@ -129,6 +129,7 @@ docker run -v $(pwd)/.v2carc.yaml:/app/.v2carc.yaml v2c-any
|
|
|
129
129
|
Emulates a **Shelly Pro EM** energy meter by exposing a REST API that V2C wallboxes can poll.
|
|
130
130
|
|
|
131
131
|
**Use this when:**
|
|
132
|
+
|
|
132
133
|
- Your V2C wallbox is configured to poll a Shelly meter
|
|
133
134
|
- You want to act as a drop-in replacement for physical hardware
|
|
134
135
|
- You prefer a pull-based (polling) approach
|
|
@@ -139,12 +140,12 @@ Emulates a **Shelly Pro EM** energy meter by exposing a REST API that V2C wallbo
|
|
|
139
140
|
provider: rest
|
|
140
141
|
properties:
|
|
141
142
|
port: 3000
|
|
142
|
-
device: shelly-pro-em
|
|
143
143
|
meters:
|
|
144
144
|
grid:
|
|
145
145
|
feed:
|
|
146
146
|
type: adapter
|
|
147
147
|
properties:
|
|
148
|
+
device: shelly-pro-em
|
|
148
149
|
ip: 192.168.1.100
|
|
149
150
|
solar:
|
|
150
151
|
feed:
|
|
@@ -162,6 +163,7 @@ properties:
|
|
|
162
163
|
```
|
|
163
164
|
|
|
164
165
|
**How it works:**
|
|
166
|
+
|
|
165
167
|
1. `v2ca` starts a Fastify HTTP server
|
|
166
168
|
2. Exposes endpoints matching Shelly Pro EM API format
|
|
167
169
|
3. V2C wallbox polls these endpoints (e.g., `/rpc/EM1.GetStatus?id=0`)
|
|
@@ -172,6 +174,7 @@ properties:
|
|
|
172
174
|
Publishes power data directly to MQTT topics that V2C wallboxes subscribe to.
|
|
173
175
|
|
|
174
176
|
**Use this when:**
|
|
177
|
+
|
|
175
178
|
- Your V2C wallbox is configured for MQTT integration
|
|
176
179
|
- You have an existing MQTT broker
|
|
177
180
|
- You want push-based (event-driven) updates
|
|
@@ -183,13 +186,13 @@ Publishes power data directly to MQTT topics that V2C wallboxes subscribe to.
|
|
|
183
186
|
provider: mqtt
|
|
184
187
|
properties:
|
|
185
188
|
url: mqtt://broker.local:1883
|
|
186
|
-
device: shelly-pro-em
|
|
187
189
|
meters:
|
|
188
190
|
grid:
|
|
189
191
|
mode: pull # v2ca polls your device
|
|
190
192
|
feed:
|
|
191
193
|
type: adapter
|
|
192
194
|
properties:
|
|
195
|
+
device: shelly-pro-em
|
|
193
196
|
interval: 2000 # Every 2 seconds
|
|
194
197
|
ip: 192.168.1.100
|
|
195
198
|
solar:
|
|
@@ -202,6 +205,7 @@ properties:
|
|
|
202
205
|
```
|
|
203
206
|
|
|
204
207
|
**How it works:**
|
|
208
|
+
|
|
205
209
|
1. `v2ca` connects to your MQTT broker
|
|
206
210
|
2. Publishes to V2C-expected topics (e.g., `trydan_v2c_sun_power`)
|
|
207
211
|
3. Supports both **pull** (polling devices) and **push** (subscribing to topics)
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Client, interceptors } from 'undici';
|
|
2
2
|
import { logger } from '../../utils/logger.js';
|
|
3
3
|
import { energyTypeToId } from '../../utils/mappers.js';
|
|
4
|
+
import { AsymmetricEMAProvider } from '../../provider/asymmetric-emea-provider.js';
|
|
5
|
+
import { RetryableProvider } from '../../provider/retryable-provider.js';
|
|
6
|
+
import { em1StatusInterpolator } from '../../utils/interpolator.js';
|
|
7
|
+
import { DecoratorProviderFactory } from '../../factory/decorator-provider-factory.js';
|
|
4
8
|
/**
|
|
5
9
|
* Provider that fetches EM1 status data from a Shelly Pro EM device via HTTP.
|
|
6
10
|
* Retrieves real-time energy monitoring data by querying the device's RPC API endpoint.
|
|
@@ -14,6 +18,9 @@ export class EM1StatusProvider {
|
|
|
14
18
|
constructor(host, energyType) {
|
|
15
19
|
this.host = host;
|
|
16
20
|
this.energyType = energyType;
|
|
21
|
+
const { responseError } = interceptors;
|
|
22
|
+
this.client = new Client(`http://${this.host}`).compose(responseError());
|
|
23
|
+
this.id = energyTypeToId(energyType);
|
|
17
24
|
}
|
|
18
25
|
/**
|
|
19
26
|
* Fetches the current EM1 status from the device.
|
|
@@ -22,8 +29,10 @@ export class EM1StatusProvider {
|
|
|
22
29
|
*/
|
|
23
30
|
async get() {
|
|
24
31
|
logger.debug({ host: this.host, energyType: this.energyType }, 'Fetching EM1Status');
|
|
25
|
-
const
|
|
26
|
-
|
|
32
|
+
const res = await this.client.request({
|
|
33
|
+
path: `/rpc/EM1.GetStatus?id=${this.id}`,
|
|
34
|
+
method: 'GET',
|
|
35
|
+
});
|
|
27
36
|
return (await res.body.json());
|
|
28
37
|
}
|
|
29
38
|
}
|
|
@@ -46,4 +55,13 @@ class EM1StatusProviderFactory {
|
|
|
46
55
|
* Singleton factory instance for creating `EM1StatusProvider` objects.
|
|
47
56
|
* Provides a ready-to-use factory to build providers with supplied options.
|
|
48
57
|
*/
|
|
49
|
-
|
|
58
|
+
const localEm1StatusProviderFactory = new EM1StatusProviderFactory();
|
|
59
|
+
export const em1StatusProviderFactory = new DecoratorProviderFactory(localEm1StatusProviderFactory, (provider) => new AsymmetricEMAProvider(new RetryableProvider(provider, { retries: 3 }), em1StatusInterpolator, {
|
|
60
|
+
alphaRise: 1,
|
|
61
|
+
alphaFall: 0.2,
|
|
62
|
+
comparator: (a, b) => {
|
|
63
|
+
const powerA = a?.act_power ?? 0;
|
|
64
|
+
const powerB = b?.act_power ?? 0;
|
|
65
|
+
return powerA - powerB;
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic factory that wraps another provider factory with a decorator pattern.
|
|
3
|
+
* Enables composition by allowing providers to be enhanced with additional behavior
|
|
4
|
+
* such as retry logic, caching, logging, validation, etc.
|
|
5
|
+
*
|
|
6
|
+
* This factory creates providers by first using the wrapped factory to create a base provider,
|
|
7
|
+
* then applying a decorator function to enhance it with additional capabilities.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // Creating a provider factory with retry capability
|
|
11
|
+
* const retryDecorator = (provider) => new RetryableProvider(provider, { retries: 3 });
|
|
12
|
+
* const resilientFactory = new DecoratorProviderFactory(baseFactory, retryDecorator);
|
|
13
|
+
* const resilientProvider = resilientFactory.create(options);
|
|
14
|
+
*
|
|
15
|
+
* @template Options - The configuration options type required by the wrapped factory
|
|
16
|
+
* @template T - The type of data the created providers will supply
|
|
17
|
+
*/
|
|
18
|
+
export class DecoratorProviderFactory {
|
|
19
|
+
/**
|
|
20
|
+
* Creates a new decorator provider factory.
|
|
21
|
+
* @param providerFactory - The underlying factory that creates base providers
|
|
22
|
+
* @param decorator - Function that enhances providers with additional behavior
|
|
23
|
+
*/
|
|
24
|
+
constructor(providerFactory, decorator) {
|
|
25
|
+
this.providerFactory = providerFactory;
|
|
26
|
+
this.decorator = decorator;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Creates a decorated provider using the wrapped factory and decorator function.
|
|
30
|
+
* First creates a base provider using the wrapped factory, then applies the decorator
|
|
31
|
+
* to enhance it with additional capabilities.
|
|
32
|
+
*
|
|
33
|
+
* @param options - Configuration options for the wrapped provider factory
|
|
34
|
+
* @returns A decorated provider with enhanced behavior
|
|
35
|
+
*/
|
|
36
|
+
create(options) {
|
|
37
|
+
const baseProvider = this.providerFactory.create(options);
|
|
38
|
+
return this.decorator(baseProvider);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -6,10 +6,10 @@ import { FixedValueProviderFactory } from '../provider/fixed-value-provider.js';
|
|
|
6
6
|
export class EM1StatusProviderFactory {
|
|
7
7
|
/**
|
|
8
8
|
* Creates a new EM1Status provider factory.
|
|
9
|
-
* @param
|
|
9
|
+
* @param providerFactoryRegistry - Registry containing provider factories for different devices
|
|
10
10
|
*/
|
|
11
|
-
constructor(
|
|
12
|
-
this.
|
|
11
|
+
constructor(providerFactoryRegistry) {
|
|
12
|
+
this.providerFactoryRegistry = providerFactoryRegistry;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* Creates the appropriate provider factory based on the feed configuration type.
|
|
@@ -20,11 +20,12 @@ export class EM1StatusProviderFactory {
|
|
|
20
20
|
createProviderFactory(options) {
|
|
21
21
|
switch (options.configuration.feed.type) {
|
|
22
22
|
case 'adapter': {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const device = options.configuration.feed.properties.device;
|
|
24
|
+
const providerFactory = this.providerFactoryRegistry.get(device);
|
|
25
|
+
if (!providerFactory) {
|
|
26
|
+
throw new Error(`No provider registered for device: ${device}`);
|
|
26
27
|
}
|
|
27
|
-
return
|
|
28
|
+
return providerFactory;
|
|
28
29
|
}
|
|
29
30
|
case 'mock':
|
|
30
31
|
return new FixedValueProviderFactory({
|
|
@@ -52,14 +52,12 @@ export class MqttFeedExecutableServiceFactory {
|
|
|
52
52
|
case 'pull':
|
|
53
53
|
return this.pullExecutableServiceFactory.create({
|
|
54
54
|
energyType: options.energyType,
|
|
55
|
-
device: options.device,
|
|
56
55
|
configuration: options.configuration.feed,
|
|
57
56
|
callbackProperties,
|
|
58
57
|
});
|
|
59
58
|
case 'push':
|
|
60
59
|
return this.pushExecutableServiceFactory.create({
|
|
61
60
|
energyType: options.energyType,
|
|
62
|
-
device: options.device,
|
|
63
61
|
configuration: options.configuration.feed,
|
|
64
62
|
callbackProperties,
|
|
65
63
|
});
|
|
@@ -10,12 +10,12 @@ import { noOpExecutableService } from '../service/no-op-executable-service.js';
|
|
|
10
10
|
export class MqttPullExecutableServiceFactory {
|
|
11
11
|
/**
|
|
12
12
|
* Creates a new MQTT pull executable service factory.
|
|
13
|
-
* @param
|
|
14
|
-
* @param
|
|
13
|
+
* @param providerFactoryRegistry - Registry of device providers for adapter-based sources
|
|
14
|
+
* @param adapterRegistry - Registry of device adapters for transforming provider output
|
|
15
15
|
*/
|
|
16
|
-
constructor(
|
|
17
|
-
this.
|
|
18
|
-
this.
|
|
16
|
+
constructor(providerFactoryRegistry, adapterRegistry) {
|
|
17
|
+
this.providerFactoryRegistry = providerFactoryRegistry;
|
|
18
|
+
this.adapterRegistry = adapterRegistry;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Creates the appropriate provider factory based on the feed configuration type.
|
|
@@ -28,16 +28,17 @@ export class MqttPullExecutableServiceFactory {
|
|
|
28
28
|
createProviderFactory(options) {
|
|
29
29
|
switch (options.configuration.type) {
|
|
30
30
|
case 'adapter': {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const device = options.configuration.properties.device;
|
|
32
|
+
const providerFactory = this.providerFactoryRegistry.get(device);
|
|
33
|
+
if (!providerFactory) {
|
|
34
|
+
throw new Error(`No provider registered for device: ${device}`);
|
|
34
35
|
}
|
|
35
|
-
const adapter = this.
|
|
36
|
+
const adapter = this.adapterRegistry.get(device);
|
|
36
37
|
if (!adapter) {
|
|
37
|
-
throw new Error(`No adapter registered for device: ${
|
|
38
|
+
throw new Error(`No adapter registered for device: ${device}`);
|
|
38
39
|
}
|
|
39
40
|
return {
|
|
40
|
-
providerFactory: new AdapterProviderFactory(
|
|
41
|
+
providerFactory: new AdapterProviderFactory(providerFactory, adapter),
|
|
41
42
|
interval: options.configuration.properties.interval,
|
|
42
43
|
};
|
|
43
44
|
}
|
|
@@ -23,9 +23,10 @@ export class MqttPushExecutableServiceFactory {
|
|
|
23
23
|
create(options) {
|
|
24
24
|
switch (options.configuration.type) {
|
|
25
25
|
case 'bridge': {
|
|
26
|
-
const
|
|
26
|
+
const device = options.configuration.properties.device;
|
|
27
|
+
const adapter = this.devicesAdapter.get(device);
|
|
27
28
|
if (!adapter) {
|
|
28
|
-
throw new Error(`No adapter registered for device: ${
|
|
29
|
+
throw new Error(`No adapter registered for device: ${device}`);
|
|
29
30
|
}
|
|
30
31
|
return new MqttBridgeService(options.configuration.properties, options.callbackProperties, adapter);
|
|
31
32
|
}
|
|
@@ -36,13 +36,11 @@ export class MqttServiceFactory {
|
|
|
36
36
|
};
|
|
37
37
|
const gridEnergyPublisher = this.mqttFeedExecutableServiceFactory.create({
|
|
38
38
|
configuration: configuration.properties.meters.grid,
|
|
39
|
-
device: configuration.properties.device,
|
|
40
39
|
energyType: 'grid',
|
|
41
40
|
callbacks,
|
|
42
41
|
});
|
|
43
42
|
const sunEnergyPublisher = this.mqttFeedExecutableServiceFactory.create({
|
|
44
43
|
configuration: configuration.properties.meters.solar,
|
|
45
|
-
device: configuration.properties.device,
|
|
46
44
|
energyType: 'solar',
|
|
47
45
|
callbacks,
|
|
48
46
|
});
|
|
@@ -20,12 +20,10 @@ export class RestServiceFactory {
|
|
|
20
20
|
create(configuration) {
|
|
21
21
|
const gridEnergyProvider = this.em1StatusProviderFactory.create({
|
|
22
22
|
energyType: 'grid',
|
|
23
|
-
device: configuration.properties.device,
|
|
24
23
|
configuration: configuration.properties.meters.grid,
|
|
25
24
|
});
|
|
26
25
|
const solarEnergyProvider = this.em1StatusProviderFactory.create({
|
|
27
26
|
energyType: 'solar',
|
|
28
|
-
device: configuration.properties.device,
|
|
29
27
|
configuration: configuration.properties.meters.solar,
|
|
30
28
|
});
|
|
31
29
|
return new RestService(gridEnergyProvider, solarEnergyProvider, {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { logger } from '../utils/logger.js';
|
|
2
|
+
import CircuitBreaker from 'opossum';
|
|
3
|
+
/**
|
|
4
|
+
* Generic Asymmetric EMA Provider using algebraic interpolators.
|
|
5
|
+
* Implements exponential moving average (EMA) with different smoothing factors
|
|
6
|
+
* for rising and falling values, allowing asymmetric response to changes.
|
|
7
|
+
*
|
|
8
|
+
* @template T - The type of value this provider supplies
|
|
9
|
+
*/
|
|
10
|
+
class InnerAsymmetricEMAProvider {
|
|
11
|
+
/**
|
|
12
|
+
* Creates a new InnerAsymmetricEMAProvider.
|
|
13
|
+
* @param provider - The underlying provider to fetch raw values from
|
|
14
|
+
* @param interpolator - The interpolator to use for blending values
|
|
15
|
+
* @param options - Configuration options for the asymmetric EMA calculation
|
|
16
|
+
* @throws {Error} If alphaRise or alphaFall is not between 0 and 1
|
|
17
|
+
*/
|
|
18
|
+
constructor(provider, interpolator, options) {
|
|
19
|
+
this.provider = provider;
|
|
20
|
+
this.interpolator = interpolator;
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.ema = null;
|
|
23
|
+
if (options.alphaRise < 0 || options.alphaRise > 1) {
|
|
24
|
+
throw new Error('alphaRise must be between 0 and 1');
|
|
25
|
+
}
|
|
26
|
+
if (options.alphaFall < 0 || options.alphaFall > 1) {
|
|
27
|
+
throw new Error('alphaFall must be between 0 and 1');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Fetches a value from the wrapped provider and updates the EMA.
|
|
32
|
+
* On first call, initializes the EMA with the fetched value.
|
|
33
|
+
* On subsequent calls, interpolates between the new value and current EMA,
|
|
34
|
+
* using alphaRise if the value is increasing or alphaFall if decreasing.
|
|
35
|
+
* @returns A promise that resolves to the updated EMA value
|
|
36
|
+
*/
|
|
37
|
+
async get() {
|
|
38
|
+
const newValue = await this.provider.get();
|
|
39
|
+
if (this.ema === null) {
|
|
40
|
+
this.ema = newValue;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// Determine if value is rising or falling
|
|
44
|
+
const comparison = this.options.comparator(newValue, this.ema);
|
|
45
|
+
const alpha = comparison >= 0 ? this.options.alphaRise : this.options.alphaFall;
|
|
46
|
+
this.ema = this.interpolator.interpolate(newValue, this.ema, alpha);
|
|
47
|
+
}
|
|
48
|
+
return this.ema;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Gets the current EMA value without fetching a new value.
|
|
52
|
+
* @returns The current EMA value, or null if not yet initialized
|
|
53
|
+
*/
|
|
54
|
+
getCurrentEMA() {
|
|
55
|
+
return this.ema;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resets the EMA to its initial state.
|
|
59
|
+
* The next call to get() will reinitialize the EMA.
|
|
60
|
+
*/
|
|
61
|
+
reset() {
|
|
62
|
+
this.ema = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resilient Asymmetric EMA Provider with circuit breaker pattern.
|
|
67
|
+
* Wraps an InnerAsymmetricEMAProvider with resilience features, providing
|
|
68
|
+
* fallback to the last known EMA value when the underlying provider fails.
|
|
69
|
+
*
|
|
70
|
+
* @template T - The type of value this provider supplies
|
|
71
|
+
*/
|
|
72
|
+
export class AsymmetricEMAProvider {
|
|
73
|
+
/**
|
|
74
|
+
* Creates a new AsymmetricEMAProvider with circuit breaker protection.
|
|
75
|
+
* @param provider - The underlying provider to fetch raw values from
|
|
76
|
+
* @param interpolator - The interpolator to use for blending values
|
|
77
|
+
* @param asymmetricEmaOptions - Configuration options for the asymmetric EMA calculation
|
|
78
|
+
*/
|
|
79
|
+
constructor(provider, interpolator, asymmetricEmaOptions) {
|
|
80
|
+
this.provider = new InnerAsymmetricEMAProvider(provider, interpolator, asymmetricEmaOptions);
|
|
81
|
+
this.circuitBreaker = new CircuitBreaker(this.provider.get.bind(this.provider), {
|
|
82
|
+
timeout: 5000, // 5 seconds
|
|
83
|
+
}).fallback(() => {
|
|
84
|
+
const fallbackValue = this.provider.getCurrentEMA();
|
|
85
|
+
if (fallbackValue) {
|
|
86
|
+
logger.warn('Using Asymmetric EMA value as fallback for ResilientProvider');
|
|
87
|
+
return fallbackValue;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
logger.error('No Asymmetric EMA value available for fallback in ResilientProvider');
|
|
91
|
+
throw new Error('No Asymmetric EMA value available for fallback');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Fetches the value from the wrapped provider with resilience features.
|
|
97
|
+
* If the underlying provider fails or times out, falls back to the last known EMA value.
|
|
98
|
+
* @returns A promise that resolves to the EMA value
|
|
99
|
+
* @throws {Error} If no EMA value is available for fallback when the provider fails
|
|
100
|
+
*/
|
|
101
|
+
get() {
|
|
102
|
+
return this.circuitBreaker.fire();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection of built-in interpolators for common data types.
|
|
3
|
+
* Provides factory methods for creating interpolators for numbers, objects, arrays,
|
|
4
|
+
* and identity transformations.
|
|
5
|
+
*/
|
|
6
|
+
export const Interpolators = {
|
|
7
|
+
/**
|
|
8
|
+
* Creates a linear interpolator for numbers.
|
|
9
|
+
* Uses the formula: alpha * newValue + (1 - alpha) * previousValue
|
|
10
|
+
* @returns An interpolator for numeric values
|
|
11
|
+
*/
|
|
12
|
+
number: () => ({
|
|
13
|
+
interpolate: (newVal, prevVal, alpha) => alpha * newVal + (1 - alpha) * prevVal,
|
|
14
|
+
}),
|
|
15
|
+
/**
|
|
16
|
+
* Creates a composite interpolator for objects.
|
|
17
|
+
* Applies property-specific interpolators to each field of the object.
|
|
18
|
+
* @template T - The object type with string keys
|
|
19
|
+
* @param interpolators - A record mapping property names to their interpolators
|
|
20
|
+
* @returns An interpolator that applies the appropriate interpolator to each property
|
|
21
|
+
*/
|
|
22
|
+
object: (interpolators) => ({
|
|
23
|
+
interpolate: (newVal, prevVal, alpha) => {
|
|
24
|
+
const result = { ...newVal };
|
|
25
|
+
for (const key in interpolators) {
|
|
26
|
+
if (Object.prototype.hasOwnProperty.call(interpolators, key)) {
|
|
27
|
+
const interpolator = interpolators[key];
|
|
28
|
+
result[key] = interpolator.interpolate(newVal[key], prevVal[key], alpha);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
/**
|
|
35
|
+
* Creates an interpolator for arrays.
|
|
36
|
+
* Applies element-wise interpolation using the provided element interpolator.
|
|
37
|
+
* Assumes arrays have matching lengths.
|
|
38
|
+
* @template T - The type of array elements
|
|
39
|
+
* @param elementInterpolator - The interpolator to apply to each array element
|
|
40
|
+
* @returns An interpolator that processes arrays element by element
|
|
41
|
+
*/
|
|
42
|
+
array: (elementInterpolator) => ({
|
|
43
|
+
interpolate: (newVal, prevVal, alpha) => newVal.map((val, i) => elementInterpolator.interpolate(val, prevVal[i], alpha)),
|
|
44
|
+
}),
|
|
45
|
+
/**
|
|
46
|
+
* Creates an identity interpolator that always returns the new value.
|
|
47
|
+
* Useful when no interpolation is desired.
|
|
48
|
+
* @template T - The type of values
|
|
49
|
+
* @returns An interpolator that ignores the previous value and alpha
|
|
50
|
+
*/
|
|
51
|
+
identity: () => ({
|
|
52
|
+
interpolate: (newVal) => newVal,
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import pRetry from 'p-retry';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Provider that wraps another provider with automatic retry logic.
|
|
5
|
+
* Uses exponential backoff strategy to retry failed operations,
|
|
6
|
+
* making the provider more resilient to transient failures.
|
|
7
|
+
*
|
|
8
|
+
* @template T - The type of value this provider supplies
|
|
9
|
+
*/
|
|
10
|
+
export class RetryableProvider {
|
|
11
|
+
/**
|
|
12
|
+
* Creates a new RetryableProvider.
|
|
13
|
+
* @param provider - The underlying provider to wrap with retry logic
|
|
14
|
+
* @param options - Configuration options for retry behavior
|
|
15
|
+
*/
|
|
16
|
+
constructor(provider, options) {
|
|
17
|
+
this.provider = provider;
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fetches a value from the wrapped provider with automatic retry on failure.
|
|
22
|
+
* Retries are performed according to the configured retry options with exponential backoff.
|
|
23
|
+
* @returns A promise that resolves to the provided value
|
|
24
|
+
* @throws {Error} If all retry attempts are exhausted
|
|
25
|
+
*/
|
|
26
|
+
get() {
|
|
27
|
+
const run = async () => {
|
|
28
|
+
return this.provider.get();
|
|
29
|
+
};
|
|
30
|
+
return pRetry(run, {
|
|
31
|
+
...this.options,
|
|
32
|
+
onFailedAttempt: (error) => {
|
|
33
|
+
logger.warn(`Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left.`);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -5,6 +5,7 @@ export const energyInformationSchema = z.object({
|
|
|
5
5
|
export const mqttPushBridgeFeedSchema = z
|
|
6
6
|
.object({
|
|
7
7
|
url: z.string(),
|
|
8
|
+
device: z.string(),
|
|
8
9
|
topic: z.string(),
|
|
9
10
|
})
|
|
10
11
|
.loose();
|
|
@@ -24,6 +25,7 @@ export const mqttPullMockFeedSchema = z
|
|
|
24
25
|
export const mqttPullAdapterFeedSchema = z
|
|
25
26
|
.object({
|
|
26
27
|
interval: z.number().int().nonnegative(),
|
|
28
|
+
device: z.string(),
|
|
27
29
|
ip: z.string(),
|
|
28
30
|
})
|
|
29
31
|
.loose();
|
|
@@ -56,7 +58,6 @@ export const mqttProviderSchema = z.object({
|
|
|
56
58
|
provider: z.literal('mqtt'),
|
|
57
59
|
properties: z.object({
|
|
58
60
|
url: z.string(),
|
|
59
|
-
device: z.string(),
|
|
60
61
|
meters: mqttMetersSchema,
|
|
61
62
|
}),
|
|
62
63
|
});
|
|
@@ -42,7 +42,12 @@ export const em1StatusSchema = z.object({
|
|
|
42
42
|
*/
|
|
43
43
|
flags: z.array(z.string()).optional(),
|
|
44
44
|
});
|
|
45
|
-
export const restAdapterFeedSchema = z
|
|
45
|
+
export const restAdapterFeedSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
ip: z.string(),
|
|
48
|
+
device: z.string(),
|
|
49
|
+
})
|
|
50
|
+
.loose();
|
|
46
51
|
export const restMockFeedSchema = z
|
|
47
52
|
.object({ value: em1StatusSchema.optional() })
|
|
48
53
|
.loose();
|
|
@@ -67,7 +72,6 @@ export const restProviderSchema = z.object({
|
|
|
67
72
|
provider: z.literal('rest'),
|
|
68
73
|
properties: z.object({
|
|
69
74
|
port: z.number().int().positive(),
|
|
70
|
-
device: z.string(),
|
|
71
75
|
meters: restMetersSchema,
|
|
72
76
|
}),
|
|
73
77
|
});
|
|
@@ -91,8 +91,6 @@ export class RestService extends AbstractExecutableService {
|
|
|
91
91
|
return reply.send({ code: -1, message: err.message });
|
|
92
92
|
}
|
|
93
93
|
});
|
|
94
|
-
// Alias for JSON-RPC style (POST)
|
|
95
|
-
//app.post('/rpc/EM1.GetStatus', async () => app.inject({ method: 'GET', url: '/rpc/EM1.GetStatus' }).then(r => r.json()));
|
|
96
94
|
await app.listen({ port: this.properties.port, host: '0.0.0.0' });
|
|
97
95
|
logger.info({ port: this.properties.port }, 'Listening');
|
|
98
96
|
logger.info('REST service started');
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Interpolators } from '../provider/interpolator.js';
|
|
2
|
+
export const em1StatusInterpolator = Interpolators.object({
|
|
3
|
+
id: Interpolators.identity(),
|
|
4
|
+
calibration: Interpolators.identity(),
|
|
5
|
+
current: Interpolators.number(),
|
|
6
|
+
voltage: Interpolators.number(),
|
|
7
|
+
act_power: Interpolators.number(),
|
|
8
|
+
aprt_power: Interpolators.number(),
|
|
9
|
+
pf: Interpolators.number(),
|
|
10
|
+
freq: Interpolators.number(),
|
|
11
|
+
errors: Interpolators.identity(),
|
|
12
|
+
flags: Interpolators.identity(),
|
|
13
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "v2c-any",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Turn any device into V2C Dynamic Power Control",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
"fastify": "^5.0.0",
|
|
51
51
|
"glob": "^13.0.0",
|
|
52
52
|
"mqtt": "^5.10.3",
|
|
53
|
+
"opossum": "^9.0.0",
|
|
54
|
+
"p-retry": "^7.1.1",
|
|
53
55
|
"pino-pretty": "^13.1.3",
|
|
54
56
|
"undici": "^7.1.0",
|
|
55
57
|
"zod": "^4.2.1"
|
|
@@ -57,6 +59,7 @@
|
|
|
57
59
|
"devDependencies": {
|
|
58
60
|
"@eslint/js": "^9.17.0",
|
|
59
61
|
"@types/node": "^22.10.1",
|
|
62
|
+
"@types/opossum": "^8.1.9",
|
|
60
63
|
"eslint": "^9.17.0",
|
|
61
64
|
"eslint-config-prettier": "^9.1.0",
|
|
62
65
|
"prettier": "^3.4.2",
|