v2c-any 0.2.1 → 0.3.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.
@@ -1,10 +1,8 @@
1
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
+ import { createResiliantProvider } from '../../utils/resiliance.js';
5
+ import { em1StatusComparator, em1StatusInterpolator, em1StatusZeroValue, } from '../../utils/interpolator.js';
8
6
  /**
9
7
  * Provider that fetches EM1 status data from a Shelly Pro EM device via HTTP.
10
8
  * Retrieves real-time energy monitoring data by querying the device's RPC API endpoint.
@@ -48,20 +46,12 @@ class EM1StatusProviderFactory {
48
46
  */
49
47
  create(options) {
50
48
  logger.debug({ ip: options.properties.ip, energyType: options.energyType }, 'Creating EM1StatusProvider');
51
- return new EM1StatusProvider(options.properties.ip, options.energyType);
49
+ const provider = new EM1StatusProvider(options.properties.ip, options.energyType);
50
+ return createResiliantProvider(provider, em1StatusInterpolator, em1StatusZeroValue, em1StatusComparator, options.properties.breaker, options.properties.retry, options.properties.ema);
52
51
  }
53
52
  }
54
53
  /**
55
54
  * Singleton factory instance for creating `EM1StatusProvider` objects.
56
55
  * Provides a ready-to-use factory to build providers with supplied options.
57
56
  */
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
- }));
57
+ export const em1StatusProviderFactory = new EM1StatusProviderFactory();
@@ -0,0 +1,86 @@
1
+ import { logger } from '../utils/logger.js';
2
+ /**
3
+ * Generic Asymmetric EMA Provider using algebraic interpolators.
4
+ * Implements exponential moving average (EMA) with different smoothing factors
5
+ * for rising and falling values, allowing asymmetric response to changes.
6
+ *
7
+ * @template T - The type of value this provider supplies
8
+ */
9
+ export class AsymmetricEMAProvider {
10
+ /**
11
+ * Creates a new InnerAsymmetricEMAProvider.
12
+ * @param provider - The underlying provider to fetch raw values from
13
+ * @param interpolator - The interpolator to use for blending values
14
+ * @param options - Configuration options for the asymmetric EMA calculation
15
+ * @throws {Error} If alphaRise or alphaFall is not between 0 and 1
16
+ */
17
+ constructor(provider, interpolator, options) {
18
+ this.provider = provider;
19
+ this.interpolator = interpolator;
20
+ this.options = options;
21
+ this.ema = null;
22
+ this.lastUpdateTime = 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
+ onNewValue(newValue) {
31
+ if (this.ema === null) {
32
+ this.ema = newValue;
33
+ }
34
+ // Determine if value is rising or falling
35
+ const comparison = this.options.comparator(newValue, this.ema);
36
+ const alpha = comparison >= 0 ? this.options.alphaRise : this.options.alphaFall;
37
+ this.ema = this.interpolator.interpolate(newValue, this.ema, alpha);
38
+ }
39
+ onMissingValue() {
40
+ if (this.ema !== null) {
41
+ this.ema = this.interpolator.interpolate(this.options.zeroValue, this.ema, this.options.alphaMissing);
42
+ }
43
+ }
44
+ /**
45
+ * Fetches a value from the wrapped provider and updates the EMA.
46
+ * On first call, initializes the EMA with the fetched value.
47
+ * On subsequent calls, interpolates between the new value and current EMA,
48
+ * using alphaRise if the value is increasing or alphaFall if decreasing.
49
+ * @returns A promise that resolves to the updated EMA value
50
+ */
51
+ async get() {
52
+ try {
53
+ const newValue = await this.provider.get();
54
+ this.lastUpdateTime = Date.now();
55
+ this.onNewValue(newValue);
56
+ return newValue;
57
+ }
58
+ catch (error) {
59
+ if (this.options.freshnessThreshold === undefined ||
60
+ this.lastUpdateTime === null ||
61
+ Date.now() - this.lastUpdateTime >= this.options.freshnessThreshold) {
62
+ logger.info('Handling missing value');
63
+ this.onMissingValue();
64
+ }
65
+ if (this.ema !== null) {
66
+ logger.warn({ ema: this.ema }, 'Returning last EMA value');
67
+ return this.ema;
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ /**
73
+ * Gets the current EMA value without fetching a new value.
74
+ * @returns The current EMA value, or null if not yet initialized
75
+ */
76
+ getCurrentEMA() {
77
+ return this.ema;
78
+ }
79
+ /**
80
+ * Resets the EMA to its initial state.
81
+ * The next call to get() will reinitialize the EMA.
82
+ */
83
+ reset(value = null) {
84
+ this.ema = value;
85
+ }
86
+ }
@@ -0,0 +1,30 @@
1
+ import CircuitBreaker from 'opossum';
2
+ /**
3
+ * Resilient Asymmetric EMA Provider with circuit breaker pattern.
4
+ * Wraps an InnerAsymmetricEMAProvider with resilience features, providing
5
+ * fallback to the last known EMA value when the underlying provider fails.
6
+ *
7
+ * @template T - The type of value this provider supplies
8
+ */
9
+ export class CircuitBreakerProvider {
10
+ /**
11
+ * Creates a new AsymmetricEMAProvider with circuit breaker protection.
12
+ * @param provider - The underlying provider to fetch raw values from
13
+ * @param interpolator - The interpolator to use for blending values
14
+ * @param asymmetricEmaOptions - Configuration options for the asymmetric EMA calculation
15
+ */
16
+ constructor(provider, options) {
17
+ this.provider = provider;
18
+ this.options = options;
19
+ this.circuitBreaker = new CircuitBreaker(this.provider.get.bind(this.provider), this.options);
20
+ }
21
+ /**
22
+ * Fetches the value from the wrapped provider with resilience features.
23
+ * If the underlying provider fails or times out, falls back to the last known EMA value.
24
+ * @returns A promise that resolves to the EMA value
25
+ * @throws {Error} If no EMA value is available for fallback when the provider fails
26
+ */
27
+ get() {
28
+ return this.circuitBreaker.fire();
29
+ }
30
+ }
@@ -1,5 +1,4 @@
1
1
  import pRetry from 'p-retry';
2
- import { logger } from '../utils/logger.js';
3
2
  /**
4
3
  * Provider that wraps another provider with automatic retry logic.
5
4
  * Uses exponential backoff strategy to retry failed operations,
@@ -27,11 +26,6 @@ export class RetryableProvider {
27
26
  const run = async () => {
28
27
  return this.provider.get();
29
28
  };
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
- });
29
+ return pRetry(run, this.options);
36
30
  }
37
31
  }
@@ -0,0 +1,24 @@
1
+ import z from 'zod';
2
+ export const breakerScehema = z.object({
3
+ timeout: z.number().int().nonnegative().optional(),
4
+ errorThresholdPercentage: z.number().min(0).max(100).optional(),
5
+ resetTimeout: z.number().int().nonnegative().optional(),
6
+ rollingCountTimeout: z.number().int().nonnegative().optional(),
7
+ rollingCountBuckets: z.number().int().positive().optional(),
8
+ volumeThreshold: z.number().int().nonnegative().optional(),
9
+ allowWarmUp: z.boolean().optional(),
10
+ });
11
+ export const retrySchema = z.object({
12
+ attempts: z.number().int().nonnegative().optional(),
13
+ factor: z.number().positive().optional(),
14
+ minTimeout: z.number().int().nonnegative().optional(),
15
+ maxTimeout: z.number().int().nonnegative().optional(),
16
+ randomize: z.boolean().optional(),
17
+ maxRetryTime: z.number().int().nonnegative().optional(),
18
+ });
19
+ export const emaSchema = z.object({
20
+ alphaRise: z.number().min(0).max(1),
21
+ alphaFall: z.number().min(0).max(1),
22
+ alphaMissing: z.number().min(0).max(1),
23
+ freshnessThreshold: z.number().int().nonnegative().optional(),
24
+ });
@@ -1,4 +1,5 @@
1
1
  import z from 'zod';
2
+ import { breakerScehema, emaSchema, retrySchema, } from './common-configuration.js';
2
3
  export const energyInformationSchema = z.object({
3
4
  power: z.number(),
4
5
  });
@@ -27,6 +28,9 @@ export const mqttPullAdapterFeedSchema = z
27
28
  interval: z.number().int().nonnegative(),
28
29
  device: z.string(),
29
30
  ip: z.string(),
31
+ breaker: breakerScehema.optional(),
32
+ retry: retrySchema.optional(),
33
+ ema: emaSchema.optional(),
30
34
  })
31
35
  .loose();
32
36
  export const mqttPullFeedSchema = z.discriminatedUnion('type', [
@@ -1,4 +1,5 @@
1
1
  import z from 'zod';
2
+ import { breakerScehema, emaSchema, retrySchema, } from './common-configuration.js';
2
3
  // EM1 status (used by mock emulator response)
3
4
  export const em1StatusSchema = z.object({
4
5
  /**
@@ -46,6 +47,9 @@ export const restAdapterFeedSchema = z
46
47
  .object({
47
48
  ip: z.string(),
48
49
  device: z.string(),
50
+ breaker: breakerScehema.optional(),
51
+ retry: retrySchema.optional(),
52
+ ema: emaSchema.optional(),
49
53
  })
50
54
  .loose();
51
55
  export const restMockFeedSchema = z
@@ -11,3 +11,20 @@ export const em1StatusInterpolator = Interpolators.object({
11
11
  errors: Interpolators.identity(),
12
12
  flags: Interpolators.identity(),
13
13
  });
14
+ export const em1StatusComparator = (a, b) => {
15
+ const powerA = a.act_power ?? 0;
16
+ const powerB = b.act_power ?? 0;
17
+ return powerA - powerB;
18
+ };
19
+ export const em1StatusZeroValue = {
20
+ id: 0,
21
+ calibration: '',
22
+ current: 0,
23
+ voltage: 0,
24
+ act_power: 0,
25
+ aprt_power: 0,
26
+ pf: 0,
27
+ freq: 0,
28
+ errors: [],
29
+ flags: [],
30
+ };
@@ -0,0 +1,48 @@
1
+ import { AsymmetricEMAProvider } from '../provider/asymmetric-ema-provider.js';
2
+ import { CircuitBreakerProvider } from '../provider/circuit-breaker-provider.js';
3
+ import { RetryableProvider } from '../provider/retryable-provider.js';
4
+ import { logger } from './logger.js';
5
+ export function createResiliantProvider(provider, interpolator, zeroValue, comparator, breakerOptions, retryOptions, emaOptions) {
6
+ let result = provider;
7
+ if (breakerOptions) {
8
+ result = new CircuitBreakerProvider(result, breakerOptions);
9
+ }
10
+ if (retryOptions) {
11
+ result = new RetryableProvider(result, {
12
+ retries: retryOptions.attempts,
13
+ factor: retryOptions.factor,
14
+ minTimeout: retryOptions.minTimeout,
15
+ maxTimeout: retryOptions.maxTimeout,
16
+ randomize: retryOptions.randomize,
17
+ maxRetryTime: retryOptions.maxRetryTime,
18
+ shouldRetry: (context) => {
19
+ const error = context.error;
20
+ if (error.code === 'EOPENBREAKER') {
21
+ // Do not retry if circuit is open
22
+ return false;
23
+ }
24
+ return true;
25
+ },
26
+ onFailedAttempt: (context) => {
27
+ const attributes = {
28
+ message: context.error.message,
29
+ attemptNumber: context.attemptNumber,
30
+ retriesLeft: context.retriesLeft,
31
+ retriesConsumed: context.retriesConsumed,
32
+ };
33
+ logger.warn({ attributes }, 'Attempt to get value failed');
34
+ },
35
+ });
36
+ }
37
+ if (emaOptions) {
38
+ result = new AsymmetricEMAProvider(result, interpolator, {
39
+ alphaRise: emaOptions?.alphaRise,
40
+ alphaFall: emaOptions?.alphaFall,
41
+ alphaMissing: emaOptions?.alphaMissing,
42
+ freshnessThreshold: emaOptions?.freshnessThreshold,
43
+ zeroValue,
44
+ comparator,
45
+ });
46
+ }
47
+ return result;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v2c-any",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Turn any device into V2C Dynamic Power Control",
6
6
  "main": "dist/index.js",
@@ -1,40 +0,0 @@
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
- }
@@ -1,104 +0,0 @@
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
- }