@matterbridge/core 3.10.9-dev-20260906-2a9dc0e → 3.10.9-dev-20260907-5fcc59a

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/dist/chipTests.js CHANGED
@@ -4,7 +4,7 @@ import { Seconds, Time } from '@matter/general';
4
4
  import { BasicInformationServer } from '@matter/node/behaviors/basic-information';
5
5
  import { BridgedDeviceBasicInformationServer } from '@matter/node/behaviors/bridged-device-basic-information';
6
6
  import { GeneralDiagnosticsServer } from '@matter/node/behaviors/general-diagnostics';
7
- import { Status, StatusResponseError } from '@matter/types';
7
+ import { MATTER_EPOCH_OFFSET_S, Status, StatusResponseError } from '@matter/types';
8
8
  import { BooleanStateConfiguration } from '@matter/types/clusters/boolean-state-configuration';
9
9
  import { ClosureControl } from '@matter/types/clusters/closure-control';
10
10
  import { CommodityMetering } from '@matter/types/clusters/commodity-metering';
@@ -70,6 +70,11 @@ const energyEvseOverTemperatureFaultTrigger = 0x0099000000000011n;
70
70
  const energyEvseFaultClearTrigger = 0x0099000000000012n;
71
71
  const energyEvseDiagnosticsCompleteTrigger = 0x0099000000000020n;
72
72
  const energyEvseTimeOfUseModeClearTrigger = 0x0099000000000021n;
73
+ const energyEvseSetSocLowTrigger = 0x0099000000000030n;
74
+ const energyEvseSetSocHighTrigger = 0x0099000000000031n;
75
+ const energyEvseSetSocClearTrigger = 0x0099000000000032n;
76
+ const energyEvseSetVehicleIdTrigger = 0x0099000000000040n;
77
+ const energyEvseRfidTrigger = 0x0099000000000050n;
73
78
  const meterIdentificationAttributesValueSetTrigger = 0x0b06000000000000n;
74
79
  const meterIdentificationTestEventClearTrigger = 0x0b06000000000001n;
75
80
  const commodityMeteringAttributesValueSetTrigger = 0x0b07000000000000n;
@@ -296,6 +301,11 @@ async function handleEnergyEvseTestEventTrigger(eventTrigger) {
296
301
  normalizeTestEventTrigger(energyEvseFaultClearTrigger),
297
302
  normalizeTestEventTrigger(energyEvseDiagnosticsCompleteTrigger),
298
303
  normalizeTestEventTrigger(energyEvseTimeOfUseModeClearTrigger),
304
+ normalizeTestEventTrigger(energyEvseSetSocLowTrigger),
305
+ normalizeTestEventTrigger(energyEvseSetSocHighTrigger),
306
+ normalizeTestEventTrigger(energyEvseSetSocClearTrigger),
307
+ normalizeTestEventTrigger(energyEvseSetVehicleIdTrigger),
308
+ normalizeTestEventTrigger(energyEvseRfidTrigger),
299
309
  ];
300
310
  if (!supportedTriggers.includes(eventTrigger) || !chipTestMatterbridge || chipTestActiveEndpointId === undefined)
301
311
  return false;
@@ -316,6 +326,9 @@ async function handleEnergyEvseTestEventTrigger(eventTrigger) {
316
326
  minimumChargeCurrent: 6_000,
317
327
  maximumChargeCurrent: 32_000,
318
328
  userMaximumChargeCurrent: 32_000,
329
+ chargingEnabledUntil: MATTER_EPOCH_OFFSET_S,
330
+ ...(endpoint.hasAttributeServer(EnergyEvse.id, 'dischargingEnabledUntil') ? { dischargingEnabledUntil: MATTER_EPOCH_OFFSET_S } : {}),
331
+ ...(endpoint.hasAttributeServer(EnergyEvse.id, 'maximumDischargeCurrent') ? { maximumDischargeCurrent: 0 } : {}),
319
332
  }, log);
320
333
  return true;
321
334
  case normalizeTestEventTrigger(energyEvseBasicClearTrigger):
@@ -323,7 +336,13 @@ async function handleEnergyEvseTestEventTrigger(eventTrigger) {
323
336
  case normalizeTestEventTrigger(energyEvsePluggedInTrigger): {
324
337
  const nextSessionId = sessionId + 1;
325
338
  energyEvseSessionStartedAt.set(chipTestActiveEndpointId, Time.nowMs);
326
- await endpoint.setCluster(EnergyEvse, { state: EnergyEvse.State.PluggedInNoDemand, sessionId: nextSessionId, sessionDuration: 0, sessionEnergyCharged: 0 }, log);
339
+ await endpoint.setCluster(EnergyEvse, {
340
+ state: EnergyEvse.State.PluggedInNoDemand,
341
+ sessionId: nextSessionId,
342
+ sessionDuration: 0,
343
+ sessionEnergyCharged: 0,
344
+ ...(endpoint.hasAttributeServer(EnergyEvse.id, 'sessionEnergyDischarged') ? { sessionEnergyDischarged: 0 } : {}),
345
+ }, log);
327
346
  await endpoint.triggerEvent(EnergyEvse, 'evConnected', { sessionId: nextSessionId }, log);
328
347
  return true;
329
348
  }
@@ -331,18 +350,26 @@ async function handleEnergyEvseTestEventTrigger(eventTrigger) {
331
350
  const sessionStartedAt = energyEvseSessionStartedAt.get(chipTestActiveEndpointId);
332
351
  const sessionDuration = sessionStartedAt === undefined ? 0 : Math.floor((Time.nowMs - sessionStartedAt) / 1_000);
333
352
  await endpoint.triggerEvent(EnergyEvse, 'evNotDetected', { sessionId, state: EnergyEvse.State.PluggedInNoDemand, sessionDuration, sessionEnergyCharged: 1 }, log);
334
- await endpoint.setCluster(EnergyEvse, { state: EnergyEvse.State.NotPluggedIn, sessionDuration, sessionEnergyCharged: 1 }, log);
353
+ await endpoint.setCluster(EnergyEvse, {
354
+ state: EnergyEvse.State.NotPluggedIn,
355
+ sessionDuration,
356
+ sessionEnergyCharged: 1,
357
+ ...(endpoint.hasAttributeServer(EnergyEvse.id, 'sessionEnergyDischarged') ? { sessionEnergyDischarged: 1 } : {}),
358
+ }, log);
335
359
  energyEvseSessionStartedAt.delete(chipTestActiveEndpointId);
336
360
  return true;
337
361
  }
338
362
  case normalizeTestEventTrigger(energyEvseChargeDemandTrigger): {
339
- const charging = endpoint.getAttribute(EnergyEvse.id, 'supplyState') === EnergyEvse.SupplyState.ChargingEnabled;
363
+ const supplyState = endpoint.getAttribute(EnergyEvse.id, 'supplyState');
364
+ const charging = supplyState === EnergyEvse.SupplyState.ChargingEnabled || supplyState === EnergyEvse.SupplyState.Enabled;
340
365
  const nextState = charging ? EnergyEvse.State.PluggedInCharging : EnergyEvse.State.PluggedInDemand;
341
366
  await endpoint.setCluster(EnergyEvse, { state: nextState }, log);
342
367
  if (charging) {
343
368
  const maximumChargeCurrentAttribute = endpoint.getAttribute(EnergyEvse.id, 'maximumChargeCurrent');
344
369
  const maximumCurrent = typeof maximumChargeCurrentAttribute === 'number' || typeof maximumChargeCurrentAttribute === 'bigint' ? maximumChargeCurrentAttribute : 0;
345
- await endpoint.triggerEvent(EnergyEvse, 'energyTransferStarted', { sessionId, state: nextState, maximumCurrent }, log);
370
+ const maximumDischargeCurrentAttribute = endpoint.getAttribute(EnergyEvse.id, 'maximumDischargeCurrent');
371
+ const maximumDischargeCurrent = typeof maximumDischargeCurrentAttribute === 'number' || typeof maximumDischargeCurrentAttribute === 'bigint' ? maximumDischargeCurrentAttribute : undefined;
372
+ await endpoint.triggerEvent(EnergyEvse, 'energyTransferStarted', { sessionId, state: nextState, maximumCurrent, maximumDischargeCurrent }, log);
346
373
  }
347
374
  return true;
348
375
  }
@@ -372,6 +399,21 @@ async function handleEnergyEvseTestEventTrigger(eventTrigger) {
372
399
  case normalizeTestEventTrigger(energyEvseDiagnosticsCompleteTrigger):
373
400
  await endpoint.setCluster(EnergyEvse, { state: EnergyEvse.State.NotPluggedIn, supplyState: EnergyEvse.SupplyState.Disabled }, log);
374
401
  return true;
402
+ case normalizeTestEventTrigger(energyEvseSetSocLowTrigger):
403
+ await endpoint.setCluster(EnergyEvse, { stateOfCharge: 20, batteryCapacity: 70_000_000 }, log);
404
+ return true;
405
+ case normalizeTestEventTrigger(energyEvseSetSocHighTrigger):
406
+ await endpoint.setCluster(EnergyEvse, { stateOfCharge: 95, batteryCapacity: 70_000_000 }, log);
407
+ return true;
408
+ case normalizeTestEventTrigger(energyEvseSetSocClearTrigger):
409
+ await endpoint.setCluster(EnergyEvse, { stateOfCharge: null, batteryCapacity: null }, log);
410
+ return true;
411
+ case normalizeTestEventTrigger(energyEvseSetVehicleIdTrigger):
412
+ await endpoint.setAttribute(EnergyEvse.id, 'vehicleId', 'Matterbridge EVSE', log);
413
+ return true;
414
+ case normalizeTestEventTrigger(energyEvseRfidTrigger):
415
+ await endpoint.triggerEvent(EnergyEvse, 'rfid', { uid: Uint8Array.of(0x4d, 0x42, 0x45, 0x56, 0x53, 0x45) }, log);
416
+ return true;
375
417
  default:
376
418
  return false;
377
419
  }
@@ -740,6 +740,20 @@ export async function createDemoDevices(matterbridge) {
740
740
  tagList: [getSemtag(CommonNumberTag.One)],
741
741
  });
742
742
  await registerDevice(ep, 'EVSE', 'ENERGY-14-01');
743
+ ep = new Evse('EVSE Complete', 'ENERGY-14-01-1', {
744
+ id: 'EvseComplete',
745
+ number: EndpointNumber(14_01_1),
746
+ tagList: [getSemtag(CommonNumberTag.Two)],
747
+ stateOfCharge: 80,
748
+ batteryCapacity: 70_000_000,
749
+ vehicleId: null,
750
+ rfid: true,
751
+ v2x: true,
752
+ esaCanGenerate: true,
753
+ absMinPower: -7_400_000,
754
+ absMaxPower: 7_400_000,
755
+ });
756
+ await registerDevice(ep, 'EVSE Complete', 'ENERGY-14-01-1');
743
757
  ep = new WaterHeater('Water Heater', 'ENERGY-14-02', {
744
758
  id: 'WaterHeater',
745
759
  number: EndpointNumber(14_02),
@@ -6,7 +6,9 @@ import { EnergyEvse } from '@matter/types/clusters/energy-evse';
6
6
  import { EnergyEvseMode } from '@matter/types/clusters/energy-evse-mode';
7
7
  import { ModeBase } from '@matter/types/clusters/mode-base';
8
8
  import type { Semtag } from '@matter/types/globals';
9
+ import { type AnsiLogger } from 'node-ansi-logger';
9
10
  import { MatterbridgeEndpoint } from '../matterbridgeEndpoint.js';
11
+ import type { ClusterAttributeValues } from '../matterbridgeEndpointCommandHandler.js';
10
12
  export interface EvseOptions {
11
13
  mode?: 'server' | 'matter';
12
14
  id?: string;
@@ -23,12 +25,19 @@ export interface EvseOptions {
23
25
  energy?: number | bigint | null;
24
26
  absMinPower?: number;
25
27
  absMaxPower?: number;
28
+ esaCanGenerate?: boolean;
29
+ stateOfCharge?: number | null;
30
+ batteryCapacity?: number | bigint | null;
31
+ vehicleId?: string | null;
32
+ rfid?: boolean;
33
+ v2x?: boolean;
26
34
  }
27
35
  export declare class Evse extends MatterbridgeEndpoint {
28
36
  constructor(name: string, serial: string, options?: EvseOptions);
29
37
  constructor(name: string, serial: string, currentMode?: number, supportedModes?: EnergyEvseMode.ModeOption[], state?: EnergyEvse.State, supplyState?: EnergyEvse.SupplyState, faultState?: EnergyEvse.FaultState, voltage?: number | bigint | null, current?: number | bigint | null, power?: number | bigint | null, energy?: number | bigint | null, absMinPower?: number, absMaxPower?: number);
30
- createDefaultEnergyEvseClusterServer(state?: EnergyEvse.State, supplyState?: EnergyEvse.SupplyState, faultState?: EnergyEvse.FaultState): this;
31
- createDefaultEnergyEvseModeClusterServer(currentMode?: number, supportedModes?: EnergyEvseMode.ModeOption[]): this;
38
+ createDefaultEnergyEvseClusterServer(state?: EnergyEvse.State, supplyState?: EnergyEvse.SupplyState, faultState?: EnergyEvse.FaultState, stateOfCharge?: number | null, batteryCapacity?: number | bigint | null, vehicleId?: string | null, rfid?: boolean, v2x?: boolean): this;
39
+ createDefaultEnergyEvseModeClusterServer(currentMode?: number, supportedModes?: EnergyEvseMode.ModeOption[], v2x?: boolean): this;
40
+ triggerRfidEvent(uid: Uint8Array, log?: AnsiLogger): Promise<boolean>;
32
41
  }
33
42
  declare const MatterbridgeEnergyEvseServer_base: import("@matter/node").ClusterBehavior.Type<typeof EnergyEvseServer, import("@matter/types").ClusterType.WithSupportedFeatures<EnergyEvse, {
34
43
  chargingPreferences: true;
@@ -40,21 +49,35 @@ declare const MatterbridgeEnergyEvseServer_base: import("@matter/node").ClusterB
40
49
  export declare class MatterbridgeEnergyEvseServer extends MatterbridgeEnergyEvseServer_base {
41
50
  #private;
42
51
  protected internal: MatterbridgeEnergyEvseServer.Internal;
52
+ state: ClusterAttributeValues<(typeof EnergyEvse)['attributes']> & MatterbridgeEnergyEvseServer.State;
43
53
  initialize(): Promise<void>;
44
54
  disable(): Promise<void>;
45
55
  enableCharging(request: EnergyEvse.EnableChargingRequest): Promise<void>;
56
+ enableDischarging(request: EnergyEvse.EnableDischargingRequest): Promise<void>;
46
57
  startDiagnostics(): Promise<void>;
47
58
  setTargets(request: EnergyEvse.SetTargetsRequest): Promise<void>;
48
59
  getTargets(): Promise<EnergyEvse.GetTargetsResponse>;
49
60
  clearTargets(): Promise<void>;
50
61
  }
51
62
  export declare namespace MatterbridgeEnergyEvseServer {
52
- class Internal {
63
+ export class Internal {
53
64
  chargingExpiryTimer: Timer | undefined;
54
- chargingTargetSchedules: EnergyEvse.ChargingTargetSchedule[];
65
+ dischargingExpiryTimer: Timer | undefined;
55
66
  maximumChargeCurrentUpdateTimer: Timer | undefined;
56
- requestedMaximumChargeCurrent: number;
57
67
  }
68
+ const State_base: new () => import("@matter/node").ClusterState.Type<import("@matter/types").ClusterType.WithSupportedFeatures<EnergyEvse, {
69
+ chargingPreferences: true;
70
+ plugAndCharge: false;
71
+ rfid: false;
72
+ soCReporting: false;
73
+ v2X: false;
74
+ }>, typeof EnergyEvseServer>;
75
+ export class State extends State_base {
76
+ chargingTargetSchedules: EnergyEvse.ChargingTargetSchedule[];
77
+ requestedMaximumChargeCurrent: number | undefined;
78
+ requestedMaximumDischargeCurrent: number | undefined;
79
+ }
80
+ export {};
58
81
  }
59
82
  export declare class MatterbridgeEnergyEvseModeServer extends EnergyEvseModeServer {
60
83
  changeToMode(request: ModeBase.ChangeToModeRequest): Promise<ModeBase.ChangeToModeResponse>;
@@ -20,8 +20,8 @@ export class Evse extends MatterbridgeEndpoint {
20
20
  super([evse], { id: options.id ?? `${name.replaceAll(' ', '')}-${serial.replaceAll(' ', '')}`, number: options.number, tagList: options.tagList, mode: options.mode });
21
21
  this.createDefaultIdentifyClusterServer()
22
22
  .createDefaultBasicInformationClusterServer(name, serial, 0xfff1, 'Matterbridge', 0x8000, 'Matterbridge Evse')
23
- .createDefaultEnergyEvseClusterServer(options.state, options.supplyState, options.faultState)
24
- .createDefaultEnergyEvseModeClusterServer(options.currentMode, options.supportedModes)
23
+ .createDefaultEnergyEvseClusterServer(options.state, options.supplyState, options.faultState, options.stateOfCharge, options.batteryCapacity, options.vehicleId, options.rfid, options.v2x)
24
+ .createDefaultEnergyEvseModeClusterServer(options.currentMode, options.supportedModes, options.v2x)
25
25
  .createDefaultTemperatureMeasurementClusterServer(24_00)
26
26
  .addRequiredClusterServers();
27
27
  fireAndForget(this.addFixedLabel('composed', 'EVSE'), this.log, 'Error adding composed label to EVSE');
@@ -32,12 +32,13 @@ export class Evse extends MatterbridgeEndpoint {
32
32
  .createDefaultElectricalEnergyMeasurementClusterServer(options.energy ?? null, 0)
33
33
  .addRequiredClusterServers();
34
34
  this.addChildDeviceType('DeviceEnergyManagement', deviceEnergyManagement)
35
- .createDefaultDeviceEnergyManagementClusterServer(DeviceEnergyManagement.EsaType.Evse, false, DeviceEnergyManagement.EsaState.Online, options.absMinPower, options.absMaxPower)
35
+ .createDefaultDeviceEnergyManagementClusterServer(DeviceEnergyManagement.EsaType.Evse, options.esaCanGenerate ?? false, DeviceEnergyManagement.EsaState.Online, options.absMinPower, options.absMaxPower)
36
36
  .createDefaultDeviceEnergyManagementModeClusterServer()
37
37
  .addRequiredClusterServers();
38
38
  }
39
- createDefaultEnergyEvseClusterServer(state, supplyState, faultState) {
40
- this.behaviors.require(MatterbridgeEnergyEvseServer.with(EnergyEvse.Feature.ChargingPreferences), {
39
+ createDefaultEnergyEvseClusterServer(state, supplyState, faultState, stateOfCharge, batteryCapacity, vehicleId, rfid, v2x) {
40
+ const evseServer = MatterbridgeEnergyEvseServer.with(EnergyEvse.Feature.ChargingPreferences, ...(stateOfCharge !== undefined ? [EnergyEvse.Feature.SoCReporting] : []), ...(vehicleId !== undefined ? [EnergyEvse.Feature.PlugAndCharge] : []), ...(rfid ? [EnergyEvse.Feature.Rfid] : []), ...(v2x ? [EnergyEvse.Feature.V2X] : []));
41
+ this.behaviors.require(rfid ? evseServer.enable({ events: { rfid: true } }) : evseServer, {
41
42
  state: state !== undefined ? state : EnergyEvse.State.NotPluggedIn,
42
43
  supplyState: supplyState !== undefined ? supplyState : EnergyEvse.SupplyState.ChargingEnabled,
43
44
  faultState: faultState !== undefined ? faultState : EnergyEvse.FaultState.NoError,
@@ -49,26 +50,44 @@ export class Evse extends MatterbridgeEndpoint {
49
50
  sessionId: null,
50
51
  sessionDuration: null,
51
52
  sessionEnergyCharged: null,
53
+ ...(stateOfCharge !== undefined ? { stateOfCharge, batteryCapacity: batteryCapacity ?? null } : {}),
54
+ ...(vehicleId !== undefined ? { vehicleId } : {}),
55
+ ...(v2x ? { dischargingEnabledUntil: null, maximumDischargeCurrent: 0, sessionEnergyDischarged: null } : {}),
52
56
  });
53
57
  return this;
54
58
  }
55
- createDefaultEnergyEvseModeClusterServer(currentMode, supportedModes) {
59
+ createDefaultEnergyEvseModeClusterServer(currentMode, supportedModes, v2x) {
56
60
  this.behaviors.require(MatterbridgeEnergyEvseModeServer, {
57
61
  supportedModes: supportedModes ?? [
58
62
  { label: 'On demand', mode: 1, modeTags: [{ value: EnergyEvseMode.ModeTag.Manual }] },
59
63
  { label: 'Scheduled', mode: 2, modeTags: [{ value: EnergyEvseMode.ModeTag.TimeOfUse }] },
60
64
  { label: 'Solar charging', mode: 3, modeTags: [{ value: EnergyEvseMode.ModeTag.SolarCharging }] },
65
+ ...(v2x ? [{ label: 'Home to vehicle and Vehicle to home', mode: 4, modeTags: [{ value: EnergyEvseMode.ModeTag.V2X }] }] : []),
61
66
  ],
62
67
  currentMode: currentMode ?? 1,
63
68
  });
64
69
  return this;
65
70
  }
71
+ async triggerRfidEvent(uid, log) {
72
+ if (![4, 7, 10].includes(uid.length)) {
73
+ (log ?? this.log).warn(`triggerRfidEvent: invalid RFID uid length ${uid.length} (expected 4, 7 or 10 bytes)`);
74
+ return false;
75
+ }
76
+ return this.triggerEvent(EnergyEvseServer.with(EnergyEvse.Feature.Rfid), 'rfid', { uid }, log);
77
+ }
66
78
  }
67
79
  export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEvse.Feature.ChargingPreferences) {
68
80
  async initialize() {
69
81
  await super.initialize();
70
- this.internal.requestedMaximumChargeCurrent = Number(this.state.maximumChargeCurrent);
71
- this.internal.chargingTargetSchedules = [];
82
+ this.state.requestedMaximumChargeCurrent ??= Number(this.state.maximumChargeCurrent);
83
+ this.state.requestedMaximumDischargeCurrent ??= this.features.v2X ? Number(this.state.maximumDischargeCurrent) : 0;
84
+ this.state.chargingTargetSchedules ??= [];
85
+ if (this.state.chargingEnabledUntil !== null) {
86
+ this.#scheduleChargingExpiry(this.state.chargingEnabledUntil);
87
+ }
88
+ if (this.features.v2X && this.state.dischargingEnabledUntil !== null) {
89
+ this.#scheduleDischargingExpiry(this.state.dischargingEnabledUntil);
90
+ }
72
91
  const userMaximumChargeCurrentChanged = this.events.userMaximumChargeCurrent$Changed;
73
92
  if (userMaximumChargeCurrentChanged) {
74
93
  this.reactTo(userMaximumChargeCurrentChanged, this.#handleUserMaximumChargeCurrentChanged);
@@ -87,6 +106,10 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
87
106
  device.log.debug(`MatterbridgeEnergyEvseServer: disable called (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
88
107
  this.state.chargingEnabledUntil = MATTER_EPOCH_OFFSET_S;
89
108
  this.#stopCharging(EnergyEvse.EnergyTransferStoppedReason.EvseStopped);
109
+ if (this.features.v2X) {
110
+ this.state.dischargingEnabledUntil = MATTER_EPOCH_OFFSET_S;
111
+ this.#stopDischarging(EnergyEvse.EnergyTransferStoppedReason.EvseStopped);
112
+ }
90
113
  }
91
114
  async enableCharging(request) {
92
115
  const device = this.endpoint.stateOf(MatterbridgeServer);
@@ -99,24 +122,56 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
99
122
  endpoint: this.endpoint,
100
123
  });
101
124
  device.log.debug(`MatterbridgeEnergyEvseServer: enableCharging called (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
102
- if (this.state.supplyState === EnergyEvse.SupplyState.DisabledDiagnostics) {
103
- throw new StatusResponse.FailureError(`MatterbridgeEnergyEvseServer: cannot enable charging while diagnostics are active (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
125
+ if (this.state.supplyState === EnergyEvse.SupplyState.DisabledError ||
126
+ this.state.supplyState === EnergyEvse.SupplyState.DisabledDiagnostics ||
127
+ this.state.faultState !== EnergyEvse.FaultState.NoError) {
128
+ throw new StatusResponse.FailureError(`MatterbridgeEnergyEvseServer: cannot enable charging while an error or diagnostics are active (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
104
129
  }
105
130
  this.internal.chargingExpiryTimer?.stop();
106
131
  this.internal.chargingExpiryTimer = undefined;
107
- this.state.supplyState = EnergyEvse.SupplyState.ChargingEnabled;
132
+ this.state.supplyState = this.#isDischargingActive() ? EnergyEvse.SupplyState.Enabled : EnergyEvse.SupplyState.ChargingEnabled;
108
133
  this.state.chargingEnabledUntil = request.chargingEnabledUntil;
109
134
  this.state.minimumChargeCurrent = request.minimumChargeCurrent;
110
- this.internal.requestedMaximumChargeCurrent = Number(request.maximumChargeCurrent);
135
+ this.state.requestedMaximumChargeCurrent = Number(request.maximumChargeCurrent);
111
136
  this.#updateMaximumChargeCurrent();
112
- this.#updateNextChargeTarget();
113
137
  if (this.state.state === EnergyEvse.State.PluggedInDemand) {
114
138
  this.state.state = EnergyEvse.State.PluggedInCharging;
115
- this.events.energyTransferStarted.emit({ sessionId: this.state.sessionId ?? 0, state: EnergyEvse.State.PluggedInCharging, maximumCurrent: this.state.maximumChargeCurrent }, this.context);
139
+ this.events.energyTransferStarted.emit({
140
+ sessionId: this.state.sessionId ?? 0,
141
+ state: this.state.state,
142
+ maximumCurrent: this.state.maximumChargeCurrent,
143
+ ...(this.features.v2X ? { maximumDischargeCurrent: this.state.maximumDischargeCurrent } : {}),
144
+ }, this.context);
116
145
  }
146
+ this.#updateNextChargeTarget();
117
147
  if (request.chargingEnabledUntil !== null) {
118
- const remainingSeconds = Math.max(0, Math.ceil(request.chargingEnabledUntil - Time.nowMs / 1000));
119
- this.internal.chargingExpiryTimer = Time.getTimer('EnergyEvse charging expiry', Seconds(remainingSeconds), this.callback(this.#expireCharging, { lock: true })).start();
148
+ this.#scheduleChargingExpiry(request.chargingEnabledUntil);
149
+ }
150
+ }
151
+ async enableDischarging(request) {
152
+ const device = this.endpoint.stateOf(MatterbridgeServer);
153
+ device.log.info(`MatterbridgeEnergyEvseServer: enableDischarging (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
154
+ await device.commandHandler.executeHandler('EnergyEvse.enableDischarging', {
155
+ command: 'enableDischarging',
156
+ request,
157
+ cluster: EnergyEvseServer.id,
158
+ attributes: this.state,
159
+ endpoint: this.endpoint,
160
+ });
161
+ device.log.debug(`MatterbridgeEnergyEvseServer: enableDischarging called (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
162
+ if (this.state.supplyState === EnergyEvse.SupplyState.DisabledError ||
163
+ this.state.supplyState === EnergyEvse.SupplyState.DisabledDiagnostics ||
164
+ this.state.faultState !== EnergyEvse.FaultState.NoError) {
165
+ throw new StatusResponse.FailureError(`MatterbridgeEnergyEvseServer: cannot enable discharging while an error or diagnostics are active (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
166
+ }
167
+ this.internal.dischargingExpiryTimer?.stop();
168
+ this.internal.dischargingExpiryTimer = undefined;
169
+ this.state.supplyState = this.#isChargingActive() ? EnergyEvse.SupplyState.Enabled : EnergyEvse.SupplyState.DischargingEnabled;
170
+ this.state.dischargingEnabledUntil = request.dischargingEnabledUntil;
171
+ this.state.requestedMaximumDischargeCurrent = Number(request.maximumDischargeCurrent);
172
+ this.#updateMaximumDischargeCurrent();
173
+ if (request.dischargingEnabledUntil !== null) {
174
+ this.#scheduleDischargingExpiry(request.dischargingEnabledUntil);
120
175
  }
121
176
  }
122
177
  async startDiagnostics() {
@@ -138,25 +193,70 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
138
193
  }
139
194
  #expireCharging() {
140
195
  this.internal.chargingExpiryTimer = undefined;
196
+ this.state.chargingEnabledUntil = MATTER_EPOCH_OFFSET_S;
141
197
  this.#stopCharging(EnergyEvse.EnergyTransferStoppedReason.EvseStopped);
142
198
  }
199
+ #expireDischarging() {
200
+ this.internal.dischargingExpiryTimer = undefined;
201
+ this.state.dischargingEnabledUntil = MATTER_EPOCH_OFFSET_S;
202
+ this.#stopDischarging(EnergyEvse.EnergyTransferStoppedReason.EvseStopped);
203
+ }
204
+ #scheduleChargingExpiry(chargingEnabledUntil) {
205
+ const remainingSeconds = Math.max(0, Math.ceil(chargingEnabledUntil - Time.nowMs / 1000));
206
+ this.internal.chargingExpiryTimer = Time.getTimer('EnergyEvse charging expiry', Seconds(remainingSeconds), this.callback(this.#expireCharging, { lock: true })).start();
207
+ }
208
+ #scheduleDischargingExpiry(dischargingEnabledUntil) {
209
+ const remainingSeconds = Math.max(0, Math.ceil(dischargingEnabledUntil - Time.nowMs / 1000));
210
+ this.internal.dischargingExpiryTimer = Time.getTimer('EnergyEvse discharging expiry', Seconds(remainingSeconds), this.callback(this.#expireDischarging, { lock: true })).start();
211
+ }
212
+ #isChargingActive() {
213
+ return this.state.supplyState === EnergyEvse.SupplyState.ChargingEnabled || this.state.supplyState === EnergyEvse.SupplyState.Enabled;
214
+ }
215
+ #isDischargingActive() {
216
+ return this.state.supplyState === EnergyEvse.SupplyState.DischargingEnabled || this.state.supplyState === EnergyEvse.SupplyState.Enabled;
217
+ }
143
218
  #handleUserMaximumChargeCurrentChanged() {
144
219
  this.internal.maximumChargeCurrentUpdateTimer?.stop();
145
220
  this.internal.maximumChargeCurrentUpdateTimer = Time.getTimer('EnergyEvse maximum charge current update', Seconds(0), this.callback(this.#updateMaximumChargeCurrent, { lock: true })).start();
146
221
  }
147
222
  #updateMaximumChargeCurrent() {
148
- this.state.maximumChargeCurrent = Math.min(Number(this.state.circuitCapacity), this.internal.requestedMaximumChargeCurrent, Number(this.state.userMaximumChargeCurrent));
223
+ this.state.maximumChargeCurrent = Math.min(Number(this.state.circuitCapacity), Number(this.state.requestedMaximumChargeCurrent), Number(this.state.userMaximumChargeCurrent));
224
+ }
225
+ #updateMaximumDischargeCurrent() {
226
+ this.state.maximumDischargeCurrent = Math.min(Number(this.state.circuitCapacity), Number(this.state.requestedMaximumDischargeCurrent));
149
227
  }
150
228
  #stopCharging(reason) {
151
229
  this.internal.chargingExpiryTimer?.stop();
152
230
  this.internal.chargingExpiryTimer = undefined;
153
231
  if (this.state.state === EnergyEvse.State.PluggedInCharging) {
154
- this.events.energyTransferStopped.emit({ sessionId: this.state.sessionId ?? 0, state: this.state.state, reason, energyTransferred: 0 }, this.context);
232
+ this.events.energyTransferStopped.emit({
233
+ sessionId: this.state.sessionId ?? 0,
234
+ state: this.state.state,
235
+ reason,
236
+ energyTransferred: 0,
237
+ ...(this.features.v2X ? { energyDischarged: 0 } : {}),
238
+ }, this.context);
155
239
  this.state.state = EnergyEvse.State.PluggedInDemand;
156
240
  }
157
- this.state.supplyState = EnergyEvse.SupplyState.Disabled;
241
+ this.state.supplyState =
242
+ this.features.v2X && this.#isPermissionActive(this.state.dischargingEnabledUntil) ? EnergyEvse.SupplyState.DischargingEnabled : EnergyEvse.SupplyState.Disabled;
243
+ this.state.minimumChargeCurrent = 0;
244
+ this.state.maximumChargeCurrent = 0;
158
245
  this.#clearNextChargeTarget();
159
246
  }
247
+ #stopDischarging(reason) {
248
+ this.internal.dischargingExpiryTimer?.stop();
249
+ this.internal.dischargingExpiryTimer = undefined;
250
+ if (this.state.state === EnergyEvse.State.PluggedInDischarging) {
251
+ this.events.energyTransferStopped.emit({ sessionId: this.state.sessionId ?? 0, state: this.state.state, reason, energyTransferred: 0, energyDischarged: 0 }, this.context);
252
+ this.state.state = EnergyEvse.State.PluggedInDemand;
253
+ }
254
+ this.state.supplyState = this.#isPermissionActive(this.state.chargingEnabledUntil) ? EnergyEvse.SupplyState.ChargingEnabled : EnergyEvse.SupplyState.Disabled;
255
+ this.state.maximumDischargeCurrent = 0;
256
+ }
257
+ #isPermissionActive(enabledUntil) {
258
+ return enabledUntil === null || enabledUntil > Time.nowMs / 1000;
259
+ }
160
260
  async setTargets(request) {
161
261
  const device = this.endpoint.stateOf(MatterbridgeServer);
162
262
  device.log.info(`MatterbridgeEnergyEvseServer: setTargets request ${debugStringify(request.chargingTargetSchedules)} (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
@@ -169,6 +269,9 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
169
269
  });
170
270
  let updatedDays = 0;
171
271
  for (const schedule of request.chargingTargetSchedules) {
272
+ if (this.features.soCReporting && schedule.chargingTargets.some((target) => target.targetSoC === undefined || target.targetSoC === null)) {
273
+ throw new StatusResponse.InvalidCommandError(`MatterbridgeEnergyEvseServer: TargetSoC is required when SoC reporting is available (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
274
+ }
172
275
  const scheduleDays = this.#encodeTargetDays(schedule.dayOfWeekForSequence);
173
276
  if ((updatedDays & scheduleDays) !== 0) {
174
277
  throw new StatusResponse.ConstraintErrorError(`MatterbridgeEnergyEvseServer: each day may occur in only one ChargingTargetSchedule (endpoint ${this.endpoint.maybeId}.${this.endpoint.maybeNumber})`);
@@ -178,11 +281,11 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
178
281
  }
179
282
  updatedDays |= scheduleDays;
180
283
  }
181
- const unchangedSchedules = this.internal.chargingTargetSchedules.flatMap((schedule) => {
284
+ const unchangedSchedules = this.state.chargingTargetSchedules.flatMap((schedule) => {
182
285
  const remainingDays = this.#encodeTargetDays(schedule.dayOfWeekForSequence) & ~updatedDays;
183
286
  return remainingDays === 0 ? [] : [{ ...schedule, dayOfWeekForSequence: new EnergyEvse.TargetDayOfWeek(remainingDays) }];
184
287
  });
185
- this.internal.chargingTargetSchedules = [...unchangedSchedules, ...structuredClone(request.chargingTargetSchedules)];
288
+ this.state.chargingTargetSchedules = [...unchangedSchedules, ...structuredClone(request.chargingTargetSchedules)];
186
289
  this.#updateNextChargeTarget();
187
290
  }
188
291
  async getTargets() {
@@ -195,7 +298,7 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
195
298
  attributes: this.state,
196
299
  endpoint: this.endpoint,
197
300
  });
198
- return { chargingTargetSchedules: structuredClone(this.internal.chargingTargetSchedules) };
301
+ return { chargingTargetSchedules: structuredClone(this.state.chargingTargetSchedules) };
199
302
  }
200
303
  async clearTargets() {
201
304
  const device = this.endpoint.stateOf(MatterbridgeServer);
@@ -207,7 +310,12 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
207
310
  attributes: this.state,
208
311
  endpoint: this.endpoint,
209
312
  });
210
- this.internal.chargingTargetSchedules = [];
313
+ this.state.chargingTargetSchedules = [];
314
+ const modeState = this.endpoint.stateOf(EnergyEvseModeServer);
315
+ const isAutomaticMode = modeState.supportedModes.some((mode) => mode.mode === modeState.currentMode && mode.modeTags.some((tag) => tag.value === EnergyEvseMode.ModeTag.TimeOfUse));
316
+ if (isAutomaticMode && this.state.state === EnergyEvse.State.PluggedInCharging) {
317
+ this.#stopCharging(EnergyEvse.EnergyTransferStoppedReason.EvseStopped);
318
+ }
211
319
  this.#clearNextChargeTarget();
212
320
  }
213
321
  #encodeTargetDays(days) {
@@ -227,14 +335,14 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
227
335
  }
228
336
  #updateNextChargeTarget() {
229
337
  this.#clearNextChargeTarget();
230
- if (this.state.supplyState !== EnergyEvse.SupplyState.ChargingEnabled || this.state.state === EnergyEvse.State.NotPluggedIn)
338
+ if (!this.#isChargingActive() || this.state.state === EnergyEvse.State.NotPluggedIn)
231
339
  return;
232
340
  const now = new Date(Time.nowMs);
233
341
  for (let dayOffset = 0; dayOffset <= 7; dayOffset++) {
234
342
  const targetDate = new Date(now);
235
343
  targetDate.setDate(now.getDate() + dayOffset);
236
344
  const dayBit = 1 << targetDate.getDay();
237
- const schedule = this.internal.chargingTargetSchedules.find((candidate) => (this.#encodeTargetDays(candidate.dayOfWeekForSequence) & dayBit) !== 0);
345
+ const schedule = this.state.chargingTargetSchedules.find((candidate) => (this.#encodeTargetDays(candidate.dayOfWeekForSequence) & dayBit) !== 0);
238
346
  if (!schedule)
239
347
  continue;
240
348
  const targets = schedule.chargingTargets.toSorted((a, b) => a.targetTimeMinutesPastMidnight - b.targetTimeMinutesPastMidnight);
@@ -243,13 +351,15 @@ export class MatterbridgeEnergyEvseServer extends EnergyEvseServer.with(EnergyEv
243
351
  if (targetDate.getTime() <= now.getTime())
244
352
  continue;
245
353
  const targetTime = Math.floor(targetDate.getTime() / 1000);
246
- const requiredEnergy = target.addedEnergy === undefined ? null : target.addedEnergy;
354
+ const useTargetSoC = target.targetSoC !== undefined && (target.addedEnergy === undefined || (this.features.soCReporting && this.state.stateOfCharge !== null));
355
+ const requiredEnergy = useTargetSoC || target.addedEnergy === undefined ? null : target.addedEnergy;
356
+ const targetSoCReached = useTargetSoC && this.state.stateOfCharge !== null && this.state.stateOfCharge >= Number(target.targetSoC);
247
357
  this.state.nextChargeTargetTime = targetTime;
248
358
  this.state.nextChargeRequiredEnergy = requiredEnergy;
249
- this.state.nextChargeTargetSoC = target.addedEnergy === undefined ? (target.targetSoC ?? null) : null;
359
+ this.state.nextChargeTargetSoC = useTargetSoC ? Number(target.targetSoC) : null;
250
360
  const maximumPowerMw = (230_000 * Number(this.state.maximumChargeCurrent)) / 1_000;
251
361
  const chargingSeconds = requiredEnergy === null || maximumPowerMw <= 0 ? 1 : Math.max(1, Math.ceil((Number(requiredEnergy) * 3_600) / maximumPowerMw));
252
- this.state.nextChargeStartTime = targetTime - chargingSeconds;
362
+ this.state.nextChargeStartTime = targetSoCReached ? null : targetTime - chargingSeconds;
253
363
  return;
254
364
  }
255
365
  }
@@ -259,11 +369,16 @@ Supervision(MatterbridgeEnergyEvseServer.prototype.constructor, 'setTargets', 'c
259
369
  (function (MatterbridgeEnergyEvseServer) {
260
370
  class Internal {
261
371
  chargingExpiryTimer;
262
- chargingTargetSchedules = [];
372
+ dischargingExpiryTimer;
263
373
  maximumChargeCurrentUpdateTimer;
264
- requestedMaximumChargeCurrent = 0;
265
374
  }
266
375
  MatterbridgeEnergyEvseServer.Internal = Internal;
376
+ class State extends EnergyEvseServer.with(EnergyEvse.Feature.ChargingPreferences).State {
377
+ chargingTargetSchedules = [];
378
+ requestedMaximumChargeCurrent = undefined;
379
+ requestedMaximumDischargeCurrent = undefined;
380
+ }
381
+ MatterbridgeEnergyEvseServer.State = State;
267
382
  })(MatterbridgeEnergyEvseServer || (MatterbridgeEnergyEvseServer = {}));
268
383
  export class MatterbridgeEnergyEvseModeServer extends EnergyEvseModeServer {
269
384
  async changeToMode(request) {
@@ -847,6 +847,7 @@ export type CommandHandlerDataMap = {
847
847
  endpoint: MatterbridgeEndpoint;
848
848
  };
849
849
  'enableCharging': CommandHandlerData<'EnergyEvse.enableCharging'>;
850
+ 'enableDischarging': CommandHandlerData<'EnergyEvse.enableDischarging'>;
850
851
  'disable': CommandHandlerData<'EnergyEvse.disable'>;
851
852
  'setTargets': CommandHandlerData<'EnergyEvse.setTargets'>;
852
853
  'getTargets': CommandHandlerData<'EnergyEvse.getTargets'>;
@@ -858,6 +859,13 @@ export type CommandHandlerDataMap = {
858
859
  attributes: ClusterAttributeValues<(typeof EnergyEvse)['attributes']>;
859
860
  endpoint: MatterbridgeEndpoint;
860
861
  };
862
+ 'EnergyEvse.enableDischarging': {
863
+ command: 'enableDischarging';
864
+ request: EnergyEvse.EnableDischargingRequest;
865
+ cluster: 'energyEvse';
866
+ attributes: ClusterAttributeValues<(typeof EnergyEvse)['attributes']>;
867
+ endpoint: MatterbridgeEndpoint;
868
+ };
861
869
  'EnergyEvse.disable': {
862
870
  command: 'disable';
863
871
  request: {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matterbridge/core",
3
- "version": "3.10.9-dev-20260906-2a9dc0e",
3
+ "version": "3.10.9-dev-20260907-5fcc59a",
4
4
  "description": "Matterbridge core library",
5
5
  "author": "https://github.com/Luligu",
6
6
  "homepage": "https://matterbridge.io/",
@@ -132,10 +132,10 @@
132
132
  },
133
133
  "dependencies": {
134
134
  "@matter/main": "0.17.9",
135
- "@matterbridge/dgram": "3.10.9-dev-20260906-2a9dc0e",
136
- "@matterbridge/thread": "3.10.9-dev-20260906-2a9dc0e",
137
- "@matterbridge/types": "3.10.9-dev-20260906-2a9dc0e",
138
- "@matterbridge/utils": "3.10.9-dev-20260906-2a9dc0e",
135
+ "@matterbridge/dgram": "3.10.9-dev-20260907-5fcc59a",
136
+ "@matterbridge/thread": "3.10.9-dev-20260907-5fcc59a",
137
+ "@matterbridge/types": "3.10.9-dev-20260907-5fcc59a",
138
+ "@matterbridge/utils": "3.10.9-dev-20260907-5fcc59a",
139
139
  "escape-html": "1.0.3",
140
140
  "express": "5.2.1",
141
141
  "express-rate-limit": "8.7.0",