iobroker.mg-saic 0.0.3 → 0.1.0

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 CHANGED
@@ -11,7 +11,9 @@ The adapter is intended for connecting MG and SAIC vehicles to ioBroker through
11
11
 
12
12
  ### Before you start
13
13
 
14
- - Node.js 22.19.0 or later is required.
14
+ - Node.js 24.0.0 or later is required.
15
+ - ioBroker js-controller 7.2.2 or later is required.
16
+ - ioBroker Admin 8.2.0 or later is required.
15
17
  - Use an iSmart account that has access to the vehicle. A separate account shared with the vehicle is recommended to avoid interfering with the mobile app session.
16
18
  - Have the account password ready. The adapter stores it as a protected setting.
17
19
  - Select the region for the account. Available regions are European Union, China, Australia, Brazil, Israel, Turkey, Thailand, and Rest of World. India is not supported.
@@ -34,7 +36,24 @@ The adapter is intended for connecting MG and SAIC vehicles to ioBroker through
34
36
 
35
37
  The adapter is published on npm but has not yet been added to the ioBroker adapter catalog.
36
38
 
39
+ ## Support
40
+
41
+ [![Donate](https://camo.githubusercontent.com/ef8c60305542390406855547e32850909812a73c4abada236de22a6dc88b313f/68747470733a2f2f7777772e70617970616c6f626a656374732e636f6d2f64655f44452f44452f692f62746e2f62746e5f646f6e61746543435f4c472e676966)](https://www.paypal.com/donate/?hosted_button_id=3RJQGSJNRSHUJ)
42
+
37
43
  ## Changelog
44
+ ### 0.1.0 (2026-11-01)
45
+
46
+ - (Voodoo2man) Reported real-time charging power in kW using the pack current and voltage.
47
+ - (Voodoo2man) Applied confirmed profile-specific charging conversions.
48
+ - (Voodoo2man) Calculated available battery energy from battery level and usable capacity.
49
+
50
+ ### 0.0.4 (2026-10-24)
51
+
52
+ - (Voodoo2man) Corrected GPS coordinates.
53
+ - (Voodoo2man) Fixed incorrect datapoint values.
54
+ - (Voodoo2man) Removed duplicate and irrelevant datapoints.
55
+ - (Voodoo2man) Set min version of admin to 8.2.0, js-controller 7.2.2, node.js 24
56
+
38
57
  ### 0.0.3 (2026-10-21)
39
58
 
40
59
  - (Voodoo2man) Improved release automation, updated Node.js release checks, and removed unpublished version metadata.
package/build/main.js CHANGED
@@ -26,6 +26,7 @@ var import_config = require("./config");
26
26
  var import_saic_client = require("./saic-client");
27
27
  var import_regions = require("./regions");
28
28
  var import_refresh_command = require("./refresh-command");
29
+ var import_pending_setting_edits = require("./pending-setting-edits");
29
30
  var import_status_metadata = require("./status-metadata");
30
31
  var import_runtime_translations = require("./runtime-translations");
31
32
  const chargingGroups = [
@@ -97,6 +98,12 @@ const settingActions = [
97
98
  { id: "chargingScheduleApply" },
98
99
  { id: "batteryHeatingScheduleApply" }
99
100
  ];
101
+ const settingInputsByAction = {
102
+ climateTemperatureApply: ["climateTemperature"],
103
+ chargingSettingsApply: ["chargeTargetSoc", "chargingCurrentLimit"],
104
+ chargingScheduleApply: ["chargingScheduleStart", "chargingScheduleEnd", "chargingScheduleMode"],
105
+ batteryHeatingScheduleApply: ["batteryHeatingScheduleEnabled", "batteryHeatingScheduleStart"]
106
+ };
100
107
  const EXPOSE_SOURCE_STATUS_DATAPOINTS = false;
101
108
  class MgSaic extends utils.Adapter {
102
109
  constructor(options = {}) {
@@ -122,6 +129,9 @@ class MgSaic extends utils.Adapter {
122
129
  lastStatusTimes = /* @__PURE__ */ new Map();
123
130
  lastCanonicalStatusTimes = /* @__PURE__ */ new Map();
124
131
  vehicleCommandsInProgress = /* @__PURE__ */ new Set();
132
+ pendingSettingEdits = /* @__PURE__ */ new Map();
133
+ pendingSettingEditsWrite = Promise.resolve();
134
+ settingEditSequence = 0;
125
135
  async onReady() {
126
136
  var _a, _b, _c, _d, _e;
127
137
  if (this.stopping) {
@@ -132,6 +142,16 @@ class MgSaic extends utils.Adapter {
132
142
  await this.extendObjectAsync("info.backend", { common: { name: (0, import_runtime_translations.getRuntimeName)("backend") } });
133
143
  await this.extendObjectAsync("info.connection", { common: { name: (0, import_runtime_translations.getRuntimeName)("connection") } });
134
144
  await this.extendObjectAsync("info.lastError", { common: { name: (0, import_runtime_translations.getRuntimeName)("lastError") } });
145
+ await this.setObjectNotExistsAsync("internal", {
146
+ type: "channel",
147
+ common: { name: "Internal" },
148
+ native: {}
149
+ });
150
+ await this.setObjectNotExistsAsync("internal.pendingSettingEdits", {
151
+ type: "state",
152
+ common: { name: "Pending setting edits", type: "string", role: "json", read: true, write: false },
153
+ native: {}
154
+ });
135
155
  await this.setObjectNotExistsAsync("commands", {
136
156
  type: "channel",
137
157
  common: { name: (0, import_runtime_translations.getRuntimeName)("commands") },
@@ -230,6 +250,7 @@ class MgSaic extends utils.Adapter {
230
250
  }
231
251
  await this.delObjectAsync(`vehicles.${vehicle.vin}.canonicalStatus`, { recursive: true });
232
252
  }
253
+ await this.restorePendingSettingEdits();
233
254
  await this.setState("info.connection", true, true);
234
255
  this.log.info(`SAIC account authenticated; discovered ${this.vehicles.length} vehicle(s).`);
235
256
  if (this.vehicles.length === 0) {
@@ -308,7 +329,7 @@ class MgSaic extends utils.Adapter {
308
329
  vehicle.vin,
309
330
  this.abortController.signal
310
331
  );
311
- await this.createVehicleChargingObjects(vehicle.vin, charging);
332
+ await this.createVehicleChargingObjects(vehicle.vin, charging, vehicle.series);
312
333
  } catch (error) {
313
334
  if (this.stopping) {
314
335
  break;
@@ -470,6 +491,7 @@ class MgSaic extends utils.Adapter {
470
491
  if (this.stopping) {
471
492
  return;
472
493
  }
494
+ const eventId = (0, import_refresh_command.normalizeStateChangeId)(id, this.namespace);
473
495
  if ((0, import_refresh_command.isRefreshCommand)(id, this.namespace, state)) {
474
496
  void this.setStateAsync("commands.refresh", { val: false, ack: true }).catch((error) => {
475
497
  const details = error instanceof Error ? error.message : "Unknown state error";
@@ -482,10 +504,10 @@ class MgSaic extends utils.Adapter {
482
504
  return;
483
505
  }
484
506
  const settingAction = settingActions.find(
485
- ({ id: actionId }) => this.vehicles.some(({ vin }) => id === `${this.namespace}.${vehicleCommandPath(vin, actionId)}`)
507
+ ({ id: actionId }) => this.vehicles.some(({ vin }) => eventId === `${this.namespace}.${vehicleCommandPath(vin, actionId)}`)
486
508
  );
487
509
  const settingVehicle = this.vehicles.find(
488
- ({ vin }) => settingAction && id === `${this.namespace}.${vehicleCommandPath(vin, settingAction.id)}`
510
+ ({ vin }) => settingAction && eventId === `${this.namespace}.${vehicleCommandPath(vin, settingAction.id)}`
489
511
  );
490
512
  if (settingAction && settingVehicle) {
491
513
  void this.executeVehicleSetting(settingVehicle.vin, settingAction.id);
@@ -493,17 +515,16 @@ class MgSaic extends utils.Adapter {
493
515
  }
494
516
  const settingInput = this.vehicles.flatMap(
495
517
  ({ vin }) => Object.keys(settingGroups).filter((stateId) => stateId !== "driverSeatHeatLevel" && stateId !== "passengerSeatHeatLevel").map((stateId) => ({ vin, stateId }))
496
- ).find(({ vin, stateId }) => id === `${this.namespace}.${vehicleCommandPath(vin, stateId)}`);
518
+ ).find(({ vin, stateId }) => eventId === `${this.namespace}.${vehicleCommandPath(vin, stateId)}`);
497
519
  if (settingInput) {
498
- void this.setStateAsync(vehicleCommandPath(settingInput.vin, settingInput.stateId), {
499
- val: state.val,
500
- ack: true
501
- });
520
+ const stateId = vehicleCommandPath(settingInput.vin, settingInput.stateId);
521
+ this.pendingSettingEdits.set(stateId, ++this.settingEditSequence);
522
+ void this.acknowledgeSettingEdit(stateId, state.val);
502
523
  return;
503
524
  }
504
525
  const seatHeating = this.vehicles.flatMap(
505
526
  ({ vin }) => ["driverSeatHeatLevel", "passengerSeatHeatLevel"].map((stateId) => ({ vin, stateId }))
506
- ).find(({ vin, stateId }) => id === `${this.namespace}.${vehicleCommandPath(vin, stateId)}`);
527
+ ).find(({ vin, stateId }) => eventId === `${this.namespace}.${vehicleCommandPath(vin, stateId)}`);
507
528
  if (seatHeating) {
508
529
  const seat = seatHeating.stateId === "driverSeatHeatLevel" ? "driver" : "passenger";
509
530
  void this.executeSeatHeating(seatHeating.vin, seat, state.val);
@@ -513,16 +534,51 @@ class MgSaic extends utils.Adapter {
513
534
  return;
514
535
  }
515
536
  const commandEntry = vehicleCommands.find(
516
- ({ id: commandId }) => this.vehicles.some(({ vin }) => id === `${this.namespace}.${vehicleCommandPath(vin, commandId)}`)
537
+ ({ id: commandId }) => this.vehicles.some(({ vin }) => eventId === `${this.namespace}.${vehicleCommandPath(vin, commandId)}`)
517
538
  );
518
539
  const vehicle = this.vehicles.find(
519
- ({ vin }) => commandEntry && id === `${this.namespace}.${vehicleCommandPath(vin, commandEntry.id)}`
540
+ ({ vin }) => commandEntry && eventId === `${this.namespace}.${vehicleCommandPath(vin, commandEntry.id)}`
520
541
  );
521
542
  if (!commandEntry || !vehicle) {
522
543
  return;
523
544
  }
524
545
  void this.executeVehicleCommand(vehicle.vin, commandEntry.id);
525
546
  }
547
+ async acknowledgeSettingEdit(stateId, value) {
548
+ try {
549
+ await this.persistPendingSettingEdits();
550
+ await this.setStateAsync(stateId, { val: value, ack: true });
551
+ } catch (error) {
552
+ const details = error instanceof Error ? error.message : "Unknown state error";
553
+ this.log.error(`Could not persist pending setting ${stateId}: ${details}`);
554
+ }
555
+ }
556
+ async persistPendingSettingEdits() {
557
+ const serialized = (0, import_pending_setting_edits.serializePendingSettingEdits)(this.pendingSettingEdits.keys());
558
+ const write = this.pendingSettingEditsWrite.catch(() => void 0).then(async () => {
559
+ await this.setStateAsync("internal.pendingSettingEdits", { val: serialized, ack: true });
560
+ });
561
+ this.pendingSettingEditsWrite = write;
562
+ await write;
563
+ }
564
+ async restorePendingSettingEdits() {
565
+ const validStateIds = new Set(
566
+ this.vehicles.flatMap(
567
+ ({ vin }) => Object.keys(settingGroups).filter((stateId) => stateId !== "driverSeatHeatLevel" && stateId !== "passengerSeatHeatLevel").map((stateId) => vehicleCommandPath(vin, stateId))
568
+ )
569
+ );
570
+ const savedState = await this.getStateAsync("internal.pendingSettingEdits");
571
+ for (const stateId of (0, import_pending_setting_edits.parsePendingSettingEdits)(savedState == null ? void 0 : savedState.val, validStateIds)) {
572
+ this.pendingSettingEdits.set(stateId, ++this.settingEditSequence);
573
+ }
574
+ for (const stateId of validStateIds) {
575
+ const currentState = await this.getStateAsync(stateId);
576
+ if (currentState && !currentState.ack && !this.pendingSettingEdits.has(stateId)) {
577
+ this.pendingSettingEdits.set(stateId, ++this.settingEditSequence);
578
+ }
579
+ }
580
+ await this.persistPendingSettingEdits();
581
+ }
526
582
  async executeSeatHeating(vin, seat, value) {
527
583
  const stateId = vehicleCommandPath(vin, `${seat}SeatHeatLevel`);
528
584
  if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 3) {
@@ -565,6 +621,9 @@ class MgSaic extends utils.Adapter {
565
621
  }
566
622
  async executeVehicleSetting(vin, action) {
567
623
  const stateId = vehicleCommandPath(vin, action);
624
+ const editVersions = new Map(
625
+ settingInputsByAction[action].map((settingId) => vehicleCommandPath(vin, settingId)).filter((settingId) => this.pendingSettingEdits.has(settingId)).map((settingId) => [settingId, this.pendingSettingEdits.get(settingId)])
626
+ );
568
627
  if (this.vehicleCommandsInProgress.has(vin)) {
569
628
  await this.setStateAsync(stateId, { val: false, ack: true });
570
629
  this.log.warn(`Ignored ${action} for ${vin}: another vehicle command is still running`);
@@ -587,6 +646,26 @@ class MgSaic extends utils.Adapter {
587
646
  setting,
588
647
  this.abortController.signal
589
648
  );
649
+ const clearedEdits = [];
650
+ for (const [settingId, version] of editVersions) {
651
+ if (this.pendingSettingEdits.get(settingId) === version) {
652
+ this.pendingSettingEdits.delete(settingId);
653
+ clearedEdits.push([settingId, version]);
654
+ }
655
+ }
656
+ try {
657
+ await this.persistPendingSettingEdits();
658
+ } catch (error) {
659
+ for (const [settingId, version] of clearedEdits) {
660
+ if (!this.pendingSettingEdits.has(settingId)) {
661
+ this.pendingSettingEdits.set(settingId, version);
662
+ }
663
+ }
664
+ const details = error instanceof Error ? error.message : "Unknown state error";
665
+ this.log.warn(
666
+ `SAIC ${action} completed for ${vin}, but pending edits could not be persisted: ${details}`
667
+ );
668
+ }
590
669
  this.log.info(`SAIC ${action} completed for ${vin}`);
591
670
  } catch (error) {
592
671
  if (!this.stopping) {
@@ -685,6 +764,10 @@ class MgSaic extends utils.Adapter {
685
764
  return;
686
765
  }
687
766
  const statusId = `vehicles.${vin}.${channel}`;
767
+ for (const key of import_status_metadata.excludedVehicleStatusFields) {
768
+ const stateId = `${statusId}.${(0, import_status_metadata.getVehicleStatusGroup)(key)}.${key}`;
769
+ await this.delObjectAsync(stateId);
770
+ }
688
771
  await this.setObjectNotExistsAsync(statusId, {
689
772
  type: "channel",
690
773
  common: { name: (0, import_runtime_translations.getRuntimeName)(channelName) },
@@ -706,6 +789,10 @@ class MgSaic extends utils.Adapter {
706
789
  if (this.stopping) {
707
790
  return;
708
791
  }
792
+ if (import_status_metadata.excludedVehicleStatusFields.some((excludedKey) => excludedKey === key)) {
793
+ await this.delObjectAsync(`${statusId}.${(0, import_status_metadata.getVehicleStatusGroup)(key)}.${key}`);
794
+ continue;
795
+ }
709
796
  const group = (0, import_status_metadata.getVehicleStatusGroup)(key);
710
797
  const groupId = `${statusId}.${group}`;
711
798
  const stateId = `${groupId}.${key}`;
@@ -720,6 +807,11 @@ class MgSaic extends utils.Adapter {
720
807
  }
721
808
  continue;
722
809
  }
810
+ const normalizedValue = (0, import_status_metadata.normalizeVehicleStatusValue)(key, value);
811
+ if (normalizedValue === void 0) {
812
+ await this.delObjectAsync(stateId);
813
+ continue;
814
+ }
723
815
  await this.setObjectNotExistsAsync(groupId, {
724
816
  type: "channel",
725
817
  common: { name: (0, import_runtime_translations.getRuntimeName)(group) },
@@ -754,32 +846,54 @@ class MgSaic extends utils.Adapter {
754
846
  if (this.stopping) {
755
847
  return;
756
848
  }
757
- await this.setStateAsync(stateId, { val: (0, import_status_metadata.normalizeVehicleStatusValue)(key, value), ack: true });
849
+ await this.setStateAsync(stateId, { val: normalizedValue, ack: true });
758
850
  if (this.stopping) {
759
851
  return;
760
852
  }
853
+ const seatSetting = key === "frontLeftSeatHeatLevel" ? "driverSeatHeatLevel" : key === "frontRightSeatHeatLevel" ? "passengerSeatHeatLevel" : void 0;
854
+ if (seatSetting && Number.isInteger(value) && value >= 0 && value <= 3) {
855
+ const settingId = vehicleCommandPath(vin, seatSetting);
856
+ const settingState = await this.getStateAsync(settingId);
857
+ if (!settingState || settingState.ack) {
858
+ await this.setStateAsync(settingId, { val: value, ack: true });
859
+ }
860
+ }
761
861
  await this.delObjectAsync(`${statusId}.${key}`);
762
862
  }
763
863
  }
764
- async createVehicleChargingObjects(vin, charging) {
864
+ async createVehicleChargingObjects(vin, charging, series) {
765
865
  await this.ensureVehicleChargingGroups(vin);
766
866
  const rootId = `vehicles.${vin}.status.charging`;
767
867
  for (const { source, id } of chargingGroups) {
768
868
  const groupId = `${rootId}.${id}`;
769
- for (const [field, value] of Object.entries(charging[source])) {
869
+ const excludedFields = id === "session" ? import_status_metadata.excludedChargingSessionFields : import_status_metadata.excludedChargingStatisticsFields;
870
+ const fields = source === "chrgMgmtData" ? (0, import_status_metadata.getChargingManagementFields)(charging.chrgMgmtData) : (0, import_status_metadata.getChargingStatisticsFields)(charging[source]);
871
+ for (const field of excludedFields) {
872
+ await this.delObjectAsync(`${groupId}.${field}`);
873
+ }
874
+ for (const [field, value] of fields) {
770
875
  if (this.stopping) {
771
876
  return;
772
877
  }
878
+ if (excludedFields.some((excludedField) => excludedField === field)) {
879
+ await this.delObjectAsync(`${groupId}.${field}`);
880
+ continue;
881
+ }
773
882
  const stateId = `${groupId}.${field}`;
774
- if (value === void 0) {
883
+ if (value === void 0 && field !== "realtimePower" && field !== "batteryAvailableEnergy") {
884
+ await this.delObjectAsync(stateId);
885
+ continue;
886
+ }
887
+ const normalizedValue = field === "realtimePower" ? (0, import_status_metadata.calculateRealtimePower)(charging.chrgMgmtData.bmsPackCrnt, charging.chrgMgmtData.bmsPackVol) : value === void 0 ? void 0 : (0, import_status_metadata.normalizeChargingValue)(field, value, series);
888
+ if (normalizedValue === void 0 && field !== "batteryAvailableEnergy") {
775
889
  await this.delObjectAsync(stateId);
776
890
  continue;
777
891
  }
778
- const metadata = (0, import_status_metadata.getChargingFieldMetadata)(field);
892
+ const metadata = (0, import_status_metadata.getChargingFieldMetadata)(field, series);
779
893
  const common = {
780
894
  name: (0, import_runtime_translations.getChargingFieldName)(field),
781
- type: metadata.type === "boolean" ? "boolean" : typeof value === "string" ? "string" : "number",
782
- role: typeof value === "string" ? "text" : metadata.role,
895
+ type: metadata.type === "boolean" ? "boolean" : typeof normalizedValue === "string" ? "string" : "number",
896
+ role: typeof normalizedValue === "string" ? "text" : metadata.role,
783
897
  read: true,
784
898
  write: false
785
899
  };
@@ -788,10 +902,48 @@ class MgSaic extends utils.Adapter {
788
902
  }
789
903
  await this.setObjectNotExistsAsync(stateId, { type: "state", common, native: {} });
790
904
  await this.extendObjectAsync(stateId, { common });
905
+ if (normalizedValue === void 0) {
906
+ continue;
907
+ }
791
908
  await this.setStateAsync(stateId, {
792
- val: (0, import_status_metadata.normalizeChargingValue)(field, value),
909
+ val: normalizedValue,
793
910
  ack: true
794
911
  });
912
+ if (field === "bmsOnBdChrgTrgtSOCDspCmd" && typeof value === "number" && value >= 1 && value <= 7) {
913
+ const targetSocId = vehicleCommandPath(vin, "chargeTargetSoc");
914
+ const targetSocState = await this.getStateAsync(targetSocId);
915
+ if ((0, import_status_metadata.shouldApplySettingFeedback)(targetSocState, this.pendingSettingEdits.has(targetSocId))) {
916
+ await this.setStateAsync(targetSocId, {
917
+ val: (0, import_status_metadata.normalizeChargingValue)(field, value, series),
918
+ ack: true
919
+ });
920
+ }
921
+ }
922
+ }
923
+ }
924
+ const capacityState = await this.getStateAsync(`${rootId}.statistics.totalBatteryCapacity`);
925
+ const socState = await this.getStateAsync(`${rootId}.session.bmsPackSOCDsp`);
926
+ const availableEnergy = (0, import_status_metadata.calculateAvailableBatteryEnergy)(
927
+ capacityState == null ? void 0 : capacityState.val,
928
+ socState == null ? void 0 : socState.val,
929
+ charging.rvsChargeStatus.packEnergyKwh
930
+ );
931
+ if (availableEnergy !== void 0) {
932
+ await this.setStateAsync(`${rootId}.statistics.batteryAvailableEnergy`, {
933
+ val: availableEnergy,
934
+ ack: true
935
+ });
936
+ } else {
937
+ await this.setStateAsync(`${rootId}.statistics.batteryAvailableEnergy`, {
938
+ val: null,
939
+ ack: true
940
+ });
941
+ }
942
+ for (const [setting, value] of Object.entries((0, import_status_metadata.getChargingSettingFeedback)(charging.chrgMgmtData))) {
943
+ const stateId = vehicleCommandPath(vin, setting);
944
+ const state = await this.getStateAsync(stateId);
945
+ if ((0, import_status_metadata.shouldApplySettingFeedback)(state, this.pendingSettingEdits.has(stateId))) {
946
+ await this.setStateAsync(stateId, { val: value, ack: true });
795
947
  }
796
948
  }
797
949
  await this.delObjectAsync(`vehicles.${vin}.charging`, { recursive: true });