hoffmation-base 3.8.0 → 4.0.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.
Files changed (127) hide show
  1. package/lib/devices/dachs/dachs.d.ts +79 -2
  2. package/lib/devices/dachs/dachs.js +214 -18
  3. package/lib/devices/devices.d.ts +36 -1
  4. package/lib/devices/devices.js +64 -0
  5. package/lib/devices/jsObject/jsObjectEnergyManager.d.ts +16 -1
  6. package/lib/devices/jsObject/jsObjectEnergyManager.js +23 -0
  7. package/lib/devices/victron/victron-device.d.ts +83 -1
  8. package/lib/devices/victron/victron-device.js +151 -2
  9. package/lib/devices/victron/victron-morning-release.d.ts +16 -0
  10. package/lib/devices/victron/victron-morning-release.js +2 -0
  11. package/lib/enums/DeviceCapability.d.ts +1 -0
  12. package/lib/enums/DeviceCapability.js +1 -0
  13. package/lib/interfaces/baseDevices/iAirQualityCollector.d.ts +2 -1
  14. package/lib/interfaces/baseDevices/iAirQualityReadings.d.ts +50 -0
  15. package/lib/interfaces/baseDevices/iAirQualityReadings.js +2 -0
  16. package/lib/interfaces/baseDevices/iAirQualitySensor.d.ts +1 -54
  17. package/lib/interfaces/baseDevices/iAirQualitySensor.js +0 -5
  18. package/lib/interfaces/baseDevices/iEnergyManager.d.ts +43 -0
  19. package/lib/interfaces/baseDevices/iHumidityCollector.d.ts +0 -1
  20. package/lib/interfaces/baseDevices/iHumidityCollector.js +0 -2
  21. package/lib/interfaces/baseDevices/iTemperatureCollector.d.ts +0 -1
  22. package/lib/interfaces/baseDevices/iTemperatureCollector.js +0 -2
  23. package/lib/interfaces/baseDevices/index.d.ts +4 -0
  24. package/lib/interfaces/baseDevices/index.js +4 -0
  25. package/lib/interfaces/baseDevices/undefinedAirQualityValue.d.ts +4 -0
  26. package/lib/interfaces/baseDevices/undefinedAirQualityValue.js +7 -0
  27. package/lib/interfaces/baseDevices/undefinedHumidityValue.d.ts +4 -0
  28. package/lib/interfaces/baseDevices/undefinedHumidityValue.js +7 -0
  29. package/lib/interfaces/baseDevices/undefinedTempValue.d.ts +4 -0
  30. package/lib/interfaces/baseDevices/undefinedTempValue.js +7 -0
  31. package/lib/interfaces/dachs/KeyListEntity.d.ts +0 -5
  32. package/lib/interfaces/dachs/KeyListEntityResponse.d.ts +6 -0
  33. package/lib/interfaces/dachs/KeyListEntityResponse.js +2 -0
  34. package/lib/interfaces/dachs/index.d.ts +1 -0
  35. package/lib/interfaces/dachs/index.js +1 -0
  36. package/lib/interfaces/deviceSettings/iDachsDeviceSettings.d.ts +15 -0
  37. package/lib/interfaces/iActuatorStateSample.d.ts +13 -0
  38. package/lib/interfaces/iActuatorStateSample.js +2 -0
  39. package/lib/interfaces/iBatteryLevelSample.d.ts +13 -0
  40. package/lib/interfaces/iBatteryLevelSample.js +2 -0
  41. package/lib/interfaces/iConsumptionWindowSample.d.ts +26 -0
  42. package/lib/interfaces/iConsumptionWindowSample.js +2 -0
  43. package/lib/interfaces/iDachsHistoryGateResult.d.ts +29 -0
  44. package/lib/interfaces/iDachsHistoryGateResult.js +2 -0
  45. package/lib/interfaces/iEnergyHistoryBasis.d.ts +26 -0
  46. package/lib/interfaces/iEnergyHistoryBasis.js +2 -0
  47. package/lib/interfaces/iEnergyHistoryEstimate.d.ts +21 -0
  48. package/lib/interfaces/iEnergyHistoryEstimate.js +2 -0
  49. package/lib/interfaces/iEnergyHistoryFeatures.d.ts +21 -0
  50. package/lib/interfaces/iEnergyHistoryFeatures.js +2 -0
  51. package/lib/interfaces/iEnergyHistoryModel.d.ts +21 -0
  52. package/lib/interfaces/iEnergyHistoryModel.js +2 -0
  53. package/lib/interfaces/iEnergyHistoryOptions.d.ts +29 -0
  54. package/lib/interfaces/iEnergyHistoryOptions.js +2 -0
  55. package/lib/interfaces/iEnergyHistoryOutlook.d.ts +41 -0
  56. package/lib/interfaces/iEnergyHistoryOutlook.js +2 -0
  57. package/lib/interfaces/iEnergyHistorySample.d.ts +19 -0
  58. package/lib/interfaces/iEnergyHistorySample.js +2 -0
  59. package/lib/interfaces/iFossilGeneratorRun.d.ts +22 -0
  60. package/lib/interfaces/iFossilGeneratorRun.js +2 -0
  61. package/lib/interfaces/iFossilGeneratorSource.d.ts +21 -0
  62. package/lib/interfaces/iFossilGeneratorSource.js +2 -0
  63. package/lib/interfaces/iMorningReserveVerdict.d.ts +38 -0
  64. package/lib/interfaces/iMorningReserveVerdict.js +2 -0
  65. package/lib/interfaces/iPersist.d.ts +51 -0
  66. package/lib/interfaces/iProjectedSocBand.d.ts +7 -0
  67. package/lib/interfaces/iProjectedSocBand.js +2 -0
  68. package/lib/interfaces/iWeatherDaySummary.d.ts +21 -0
  69. package/lib/interfaces/iWeatherDaySummary.js +2 -0
  70. package/lib/interfaces/index.d.ts +16 -0
  71. package/lib/interfaces/index.js +16 -0
  72. package/lib/services/dbo/actuator-state-row.d.ts +14 -0
  73. package/lib/services/dbo/actuator-state-row.js +2 -0
  74. package/lib/services/dbo/battery-level-row.d.ts +16 -0
  75. package/lib/services/dbo/battery-level-row.js +2 -0
  76. package/lib/services/dbo/energy-consumption-row.d.ts +16 -0
  77. package/lib/services/dbo/energy-consumption-row.js +2 -0
  78. package/lib/services/dbo/postgreSqlPersist.d.ts +48 -1
  79. package/lib/services/dbo/postgreSqlPersist.js +298 -28
  80. package/lib/services/dbo/weather-day-summary-row.d.ts +18 -0
  81. package/lib/services/dbo/weather-day-summary-row.js +2 -0
  82. package/lib/services/energy/energy-history-service.d.ts +168 -0
  83. package/lib/services/energy/energy-history-service.js +499 -0
  84. package/lib/services/energy/index.d.ts +1 -0
  85. package/lib/services/energy/index.js +17 -0
  86. package/lib/services/energy/plant-energy-dials.d.ts +22 -0
  87. package/lib/services/energy/plant-energy-dials.js +2 -0
  88. package/lib/services/https-service.d.ts +48 -2
  89. package/lib/services/https-service.js +136 -10
  90. package/lib/services/index.d.ts +1 -0
  91. package/lib/services/index.js +1 -0
  92. package/lib/services/time-callback-service.d.ts +12 -0
  93. package/lib/services/time-callback-service.js +16 -2
  94. package/lib/services/weather/index.d.ts +1 -0
  95. package/lib/services/weather/index.js +1 -0
  96. package/lib/services/weather/open-weather-day-summary.d.ts +21 -0
  97. package/lib/services/weather/open-weather-day-summary.js +2 -0
  98. package/lib/services/weather/weather-day-summary-fetcher.d.ts +6 -0
  99. package/lib/services/weather/weather-day-summary-fetcher.js +2 -0
  100. package/lib/services/weather/weather-history-backfill.d.ts +88 -0
  101. package/lib/services/weather/weather-history-backfill.js +228 -0
  102. package/lib/services/weather/weather-service.js +29 -4
  103. package/lib/settings-service-instance.js +5 -2
  104. package/lib/settingsObjects/deviceSettings/dachsSettings.d.ts +6 -0
  105. package/lib/settingsObjects/deviceSettings/dachsSettings.js +14 -1
  106. package/lib/settingsObjects/deviceSettings/victronDeviceSettings.d.ts +116 -0
  107. package/lib/settingsObjects/deviceSettings/victronDeviceSettings.js +150 -5
  108. package/lib/tsconfig.tsbuildinfo +1 -1
  109. package/lib/utils/dayMs.d.ts +7 -0
  110. package/lib/utils/dayMs.js +10 -0
  111. package/lib/utils/energy-history-dials.d.ts +19 -0
  112. package/lib/utils/energy-history-dials.js +2 -0
  113. package/lib/utils/energy-history-utils.d.ts +143 -0
  114. package/lib/utils/energy-history-utils.js +470 -0
  115. package/lib/utils/energy-manager-utils.d.ts +123 -1
  116. package/lib/utils/energy-manager-utils.js +226 -0
  117. package/lib/utils/index.d.ts +4 -0
  118. package/lib/utils/index.js +4 -0
  119. package/lib/utils/model-shadow.d.ts +61 -0
  120. package/lib/utils/model-shadow.js +83 -0
  121. package/lib/utils/morning-reserve-dials.d.ts +21 -0
  122. package/lib/utils/morning-reserve-dials.js +2 -0
  123. package/lib/utils/morning-verdict.d.ts +5 -0
  124. package/lib/utils/morning-verdict.js +2 -0
  125. package/lib/utils/utils.d.ts +0 -1
  126. package/lib/utils/utils.js +5 -5
  127. package/package.json +3 -2
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The raw shape the driver hands back for a stored battery level.
3
+ *
4
+ * Numeric columns arrive as a number or a string depending on their postgres type, so they are converted
5
+ * rather than passed through. The timestamp column is `timestamp without time zone` and therefore a Date built
6
+ * from naive components. It is deliberately not typed as `string | Date`: the driver never hands a string back
7
+ * for that column, and pretending otherwise is what hid a timezone offset for a long time.
8
+ *
9
+ * Not part of the published surface - a database row shape is not an interface anyone implements.
10
+ */
11
+ export type BatteryLevelRow = {
12
+ /** The stored state of charge, as the driver hands it back */
13
+ batteryLevel: string | number | null;
14
+ /** End of the interval the reading closes, built from naive components */
15
+ endDate: Date | null;
16
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The raw shape the driver hands back for one stored energy calculation interval.
3
+ *
4
+ * Same conversion rules as {@link BatteryLevelRow}: numeric columns may arrive as a string, and the timestamp
5
+ * is a Date built from naive components because the column carries no zone.
6
+ *
7
+ * Not part of the published surface - a database row shape is not an interface anyone implements.
8
+ */
9
+ export type EnergyConsumptionRow = {
10
+ /** Energy the house took from its own production over the interval, in kWh */
11
+ selfConsumedKwH: string | number | null;
12
+ /** Energy the house took from the grid over the interval, in kWh */
13
+ drawnKwH: string | number | null;
14
+ /** End of the interval the reading closes, built from naive components */
15
+ endDate: Date | null;
16
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,5 +1,5 @@
1
1
  import { PoolConfig } from 'pg';
2
- import { iAcDevice, iActuator, iBaseDevice, iBatteryDevice, iButtonSwitch, iDesiredShutterPosition, iHandle, iHeater, iAirQualityCollector, iHumidityCollector, iIlluminationSensor, iMotionSensor, iPersist, iRoomBase, iShutter, iShutterCalibration, iTemperatureCollector, iTemperatureMeasurement, iZigbeeDevice } from '../../interfaces';
2
+ import { iAcDevice, iActuator, iActuatorStateSample, iBaseDevice, iBatteryDevice, iBatteryLevelSample, iButtonSwitch, iConsumptionWindowSample, iDesiredShutterPosition, iHandle, iHeater, iAirQualityCollector, iHumidityCollector, iIlluminationSensor, iMotionSensor, iPersist, iRoomBase, iShutter, iShutterCalibration, iTemperatureCollector, iTemperatureMeasurement, iWeatherDaySummary, iZigbeeDevice } from '../../interfaces';
3
3
  import { CountToday, EnergyCalculation } from '../../models';
4
4
  import { ButtonPressType } from '../../enums';
5
5
  export declare class PostgreSqlPersist implements iPersist {
@@ -22,6 +22,16 @@ export declare class PostgreSqlPersist implements iPersist {
22
22
  /** @inheritDoc */
23
23
  getTemperatureHistory(deviceId: string, startDate?: Date, endDate?: Date): Promise<iTemperatureMeasurement[]>;
24
24
  /** @inheritDoc */
25
+ getBatteryLevelHistory(startDate: Date, endDate: Date): Promise<iBatteryLevelSample[]>;
26
+ /** @inheritDoc */
27
+ getEnergyConsumptionHistory(startDate: Date, endDate: Date): Promise<iConsumptionWindowSample[]>;
28
+ /** @inheritDoc */
29
+ getActuatorHistory(deviceId: string, startDate: Date, endDate: Date): Promise<iActuatorStateSample[]>;
30
+ /** @inheritDoc */
31
+ getWeatherDaySummaries(startDate: Date, endDate: Date): Promise<iWeatherDaySummary[]>;
32
+ /** @inheritDoc */
33
+ persistWeatherDaySummary(summary: iWeatherDaySummary): void;
34
+ /** @inheritDoc */
25
35
  initialize(): Promise<void>;
26
36
  /** @inheritDoc */
27
37
  persistAC(device: iAcDevice): void;
@@ -57,6 +67,43 @@ export declare class PostgreSqlPersist implements iPersist {
57
67
  persistSettings(id: string, settings: string, customName: string): void;
58
68
  /** @inheritDoc */
59
69
  loadSettings(id: string): Promise<string | undefined>;
70
+ /**
71
+ * Runs a statement, optionally with bind values.
72
+ *
73
+ * `values` is what makes a statement able to carry data that did not come from this process: the driver
74
+ * sends text and values apart, so a value is never parsed as SQL, whatever it spells. Anything that is not
75
+ * a literal fixed by this file belongs in there rather than in the string.
76
+ * @param query - The statement, with `$1..$n` where values are bound
77
+ * @param values - The values to bind, in the order of their placeholders
78
+ * @returns - The rows, or null when the statement did not come back
79
+ */
60
80
  private query;
81
+ /**
82
+ * A missing or unreadable history is a defined state of the history based decisions, not a failure - but it
83
+ * has to be visible once, otherwise "no answer" and "nothing recorded" look the same from the outside.
84
+ * @param reader - The reader that came up empty
85
+ * @param dbResult - null when the query itself did not come back, an empty set otherwise
86
+ * @param startDate - Start of the requested window
87
+ * @param endDate - End of the requested window
88
+ */
89
+ private static logEmptyAnswer;
90
+ private static logDroppedRows;
91
+ /**
92
+ * Turns what the driver made of a `timestamp without time zone` back into the instant it was stored for.
93
+ *
94
+ * The write path stores `new Date(...).toISOString()`. For a naive column PostgreSQL silently drops the
95
+ * zone suffix of the literal and keeps the UTC wall clock. Reading it back, the driver rebuilds those
96
+ * components with the **local** multi argument Date constructor (`postgres-date/index.js:49-50`, registered
97
+ * for OID 1114 in `pg-types/lib/textParsers.js:175`), so the Date is off by the machine's zone offset - one
98
+ * hour or two in Berlin, and the offset changes inside a single 90 day window at the daylight saving
99
+ * switch. Reading the local components back out as UTC undoes exactly that.
100
+ *
101
+ * Only the read side compensates: `toISOString()` already stores the UTC wall clock and three years of
102
+ * recorded history are written that way, so changing the write path would put old and new rows at odds.
103
+ * @param value - The Date the driver produced, or null for an absent timestamp
104
+ * @returns - The instant, or undefined when there is none to be had
105
+ */
106
+ private static fromNaiveTimestamp;
107
+ private static toFiniteNumber;
61
108
  private isPsqlReady;
62
109
  }
@@ -16,40 +16,52 @@ class PostgreSqlPersist {
16
16
  }
17
17
  /** @inheritDoc */
18
18
  addRoom(room) {
19
+ // The name is bound rather than pasted in: it is a text a person gave, and an apostrophe in one - a room
20
+ // called `Anna's Zimmer` - closes the literal it would sit in. The statement would then be unparseable,
21
+ // the insert would fail with a logged warning, and the room would silently not be recorded. The floor is
22
+ // a number and stays this file's own text.
19
23
  this.query(`
20
24
  insert into hoffmation_schema."BasicRooms" (name, etage)
21
- values ('${room.roomName}', ${room.etage}) ON CONFLICT (name)
25
+ values ($1, ${room.etage}) ON CONFLICT (name)
22
26
  DO
23
27
  UPDATE SET
24
28
  etage = ${room.etage}
25
29
  ;
26
- `);
30
+ `, [room.roomName]);
27
31
  }
28
32
  /** @inheritDoc */
29
33
  addDevice(device) {
34
+ // Room, key and custom name are texts a person gave, and the id is a text as well - each of them can
35
+ // close the literal it would be pasted into. This row is the one the device data tables reference, so a
36
+ // failed insert here is not one lost record but a whole device missing its anchor. The update half binds
37
+ // the same placeholders: it is the one an interpolation is most easily left behind in. The device type is
38
+ // a number and stays this file's own text.
30
39
  this.query(`
31
40
  insert into hoffmation_schema."DeviceInfo" ("deviceid", "roomname", "alldeviceskey", "customname", "devtype")
32
- values ('${device.id}', '${device.info.room}', '${device.info.allDevicesKey}', '${device.info.customName}',
41
+ values ($1, $2, $3, $4,
33
42
  ${device.deviceType}) ON CONFLICT ("deviceid")
34
43
  DO
35
44
  UPDATE SET
36
- "roomname" = '${device.info.room}',
37
- "alldeviceskey" = '${device.info.allDevicesKey}',
38
- "customname" = '${device.info.customName}',
45
+ "roomname" = $2,
46
+ "alldeviceskey" = $3,
47
+ "customname" = $4,
39
48
  "devtype" = ${device.deviceType}
40
49
  ;
41
- `);
50
+ `, [device.id, device.info.room, device.info.allDevicesKey, device.info.customName]);
42
51
  }
43
52
  /** @inheritDoc */
44
53
  async getLastDesiredPosition(device) {
54
+ // The device id is bound rather than pasted in: it is a string, and a string inside quotes can close
55
+ // them. The rest of the filter is this file's own text and stays in the statement - a bound id compares
56
+ // against the column exactly as the literal did, so the reader answers what it always answered.
45
57
  const dbResult = await this.query(`SELECT position
46
58
  from hoffmation_schema."ShutterDeviceData"
47
- WHERE "deviceID" = '${device.id}'
59
+ WHERE "deviceID" = $1
48
60
  and date >= CURRENT_DATE
49
61
  AND date
50
62
  < CURRENT_DATE + INTERVAL '1 DAY'
51
63
  ORDER BY date desc
52
- Limit 1`);
64
+ Limit 1`, [device.id]);
53
65
  if (dbResult !== null && dbResult.length > 0) {
54
66
  return dbResult[0];
55
67
  }
@@ -58,13 +70,15 @@ class PostgreSqlPersist {
58
70
  }
59
71
  /** @inheritDoc */
60
72
  async motionSensorTodayCount(device) {
73
+ // Bound for the same reason as the reader above, and with the same result: the count is taken over the
74
+ // rows whose id equals this string, whatever the string spells.
61
75
  const dbResult = await this.query(`SELECT Count(*)
62
76
  from hoffmation_schema."MotionSensorDeviceData"
63
- WHERE "deviceID" = '${device.id}'
77
+ WHERE "deviceID" = $1
64
78
  and "movementDetected"
65
79
  and date >= CURRENT_DATE
66
80
  AND date
67
- < CURRENT_DATE + INTERVAL '1 DAY'`);
81
+ < CURRENT_DATE + INTERVAL '1 DAY'`, [device.id]);
68
82
  if (dbResult !== null && dbResult.length > 0) {
69
83
  const result = dbResult[0];
70
84
  result.count = Number(result.count);
@@ -87,25 +101,198 @@ class PostgreSqlPersist {
87
101
  async getTemperatureHistory(deviceId, startDate, endDate) {
88
102
  const end = endDate !== null && endDate !== void 0 ? endDate : new Date();
89
103
  const start = startDate !== null && startDate !== void 0 ? startDate : new Date(end.getTime() - 24 * 60 * 60 * 1000);
104
+ // The device id is bound rather than pasted in: it is a string this reader is handed, and a string inside
105
+ // quotes can close them. The dates travel the same way for consistency - bound as ISO text, which is what
106
+ // the naive column stores and hence the same comparison the statement makes with a literal.
90
107
  const dbResult = await this.query(`SELECT temperature, date
91
108
  from hoffmation_schema."TemperatureSensorDeviceData"
92
- WHERE "deviceID" = '${deviceId}'
93
- and date >= '${start.toISOString()}'
94
- AND date <= '${end.toISOString()}'
95
- ORDER BY DATE DESC`);
109
+ WHERE "deviceID" = $1
110
+ and date >= $2
111
+ AND date <= $3
112
+ ORDER BY DATE DESC`, [deviceId, start.toISOString(), end.toISOString()]);
96
113
  if (dbResult === null || dbResult.length === 0) {
114
+ PostgreSqlPersist.logEmptyAnswer('getTemperatureHistory', dbResult, start, end);
97
115
  return [];
98
116
  }
99
117
  const result = [];
118
+ let dropped = 0;
100
119
  for (const entry of dbResult) {
120
+ // The column is nullable and the write path stores a null whenever the sensor has nothing to say.
121
+ // Converted directly an absent value becomes 0, and 0 °C is not an impossible reading but an ordinary
122
+ // winter one - so it never looks wrong downstream, it only pulls every average it enters towards
123
+ // freezing. A genuine zero survives: it is a temperature this installation really sees, and only an
124
+ // absent or unreadable value is dropped.
125
+ const temperature = PostgreSqlPersist.toFiniteNumber(entry.temperature);
126
+ // A measurement without a timestamp cannot be placed: read as a Date an absent one lands on
127
+ // 1970-01-01, which looks like a reading at the far edge of the window rather than like a missing one.
128
+ const date = PostgreSqlPersist.fromNaiveTimestamp(entry.date);
129
+ if (temperature === undefined || date === undefined) {
130
+ dropped++;
131
+ continue;
132
+ }
101
133
  result.push({
102
- temperature: Number(entry.temperature),
103
- date: new Date(entry.date),
134
+ temperature: temperature,
135
+ date: date,
104
136
  });
105
137
  }
138
+ PostgreSqlPersist.logDroppedRows('getTemperatureHistory', dropped);
139
+ return result;
140
+ }
141
+ /** @inheritDoc */
142
+ async getBatteryLevelHistory(startDate, endDate) {
143
+ const dbResult = await this.query(`SELECT "batteryLevel", "endDate"
144
+ from hoffmation_schema."EnergyCalculation"
145
+ WHERE "endDate" >= '${startDate.toISOString()}'
146
+ AND "endDate" <= '${endDate.toISOString()}'
147
+ ORDER BY "endDate" DESC`);
148
+ if (dbResult === null || dbResult.length === 0) {
149
+ PostgreSqlPersist.logEmptyAnswer('getBatteryLevelHistory', dbResult, startDate, endDate);
150
+ return [];
151
+ }
152
+ const result = [];
153
+ let dropped = 0;
154
+ for (const entry of dbResult) {
155
+ // "batteryLevel" was added to an existing table (see initialize()), so early rows carry no level.
156
+ // Reading such a row as 0 % would invent a nightly low that never happened. The level is read at the
157
+ // end of the interval (victron-device.ts:279-283 sets it right before persisting), so the reading is
158
+ // dated with "endDate" - the same time base the consumption readings use.
159
+ const stored = PostgreSqlPersist.toFiniteNumber(entry.batteryLevel);
160
+ const date = PostgreSqlPersist.fromNaiveTimestamp(entry.endDate);
161
+ if (stored === undefined || date === undefined) {
162
+ dropped++;
163
+ continue;
164
+ }
165
+ // The column holds a fraction, not a percentage: victron-device.ts:279 divides the device's percentage
166
+ // by 100 before persisting. Everything downstream of here - the reserve, the corrected sample, the
167
+ // thresholds of the unit itself - is in percent, so the conversion happens here. Do not "tidy" this
168
+ // multiplication away without changing that line.
169
+ const level = utils_1.Utils.round(stored * 100, 1);
170
+ // Outside 0..100 this is not a state of charge: victron-device.ts:89-95 reports -1 when the battery
171
+ // says nothing, which lands in the column as -0.01. An invented trough is worse than a missing one.
172
+ // A plain 0 counts as "not reported" as well, and the trade-off behind that is deliberate: the column
173
+ // cannot tell "not reported" from "really empty", because iEnergyCalculation.batteryLevel starts at 0
174
+ // and an energy manager that never sets it (JsObjectEnergyManager) persists that 0 on every row. The
175
+ // price is that a genuine zero reading is discarded - which beats fitting the model on an invented one,
176
+ // and a battery that reports 0 % does not occur in practice: its management cuts off well before, and
177
+ // an unknown level arrives as -1, never as 0.
178
+ if (level <= 0 || level > 100) {
179
+ dropped++;
180
+ continue;
181
+ }
182
+ result.push({ level: level, date: date });
183
+ }
184
+ PostgreSqlPersist.logDroppedRows('getBatteryLevelHistory', dropped);
185
+ return result;
186
+ }
187
+ /** @inheritDoc */
188
+ async getEnergyConsumptionHistory(startDate, endDate) {
189
+ const dbResult = await this.query(`SELECT "selfConsumedKwH", "drawnKwH", "endDate"
190
+ from hoffmation_schema."EnergyCalculation"
191
+ WHERE "endDate" >= '${startDate.toISOString()}'
192
+ AND "endDate" <= '${endDate.toISOString()}'
193
+ ORDER BY "endDate" DESC`);
194
+ if (dbResult === null || dbResult.length === 0) {
195
+ PostgreSqlPersist.logEmptyAnswer('getEnergyConsumptionHistory', dbResult, startDate, endDate);
196
+ return [];
197
+ }
198
+ const result = [];
199
+ let dropped = 0;
200
+ for (const entry of dbResult) {
201
+ // What the house used out of its own generation plus what it pulled from the grid. "injectedKwH" is
202
+ // export and deliberately absent - both energy managers build the three so that
203
+ // selfConsuming + drawing == total house consumption (phaseState.ts:11-14, victron-device.ts:126).
204
+ const selfConsumed = PostgreSqlPersist.toFiniteNumber(entry.selfConsumedKwH);
205
+ const drawn = PostgreSqlPersist.toFiniteNumber(entry.drawnKwH);
206
+ // The row's energy belongs to the interval it closes, so it is timestamped with its end - a reading
207
+ // dated at its start would be counted into the following window instead of its own.
208
+ const date = PostgreSqlPersist.fromNaiveTimestamp(entry.endDate);
209
+ if (selfConsumed === undefined || drawn === undefined || date === undefined) {
210
+ dropped++;
211
+ continue;
212
+ }
213
+ result.push({ consumedKwh: selfConsumed + drawn, date: date });
214
+ }
215
+ PostgreSqlPersist.logDroppedRows('getEnergyConsumptionHistory', dropped);
216
+ return result;
217
+ }
218
+ /** @inheritDoc */
219
+ async getActuatorHistory(deviceId, startDate, endDate) {
220
+ // The device id is bound rather than pasted in: it is a string this reader is handed, and a string
221
+ // inside quotes can close them. The dates travel the same way for consistency - bound as ISO text, which
222
+ // is what the naive column stores and hence the same comparison the statement makes with a literal.
223
+ const dbResult = await this.query(`SELECT "on", date
224
+ from hoffmation_schema."ActuatorDeviceData"
225
+ WHERE "deviceID" = $1
226
+ and date >= $2
227
+ AND date <= $3
228
+ ORDER BY DATE DESC`, [deviceId, startDate.toISOString(), endDate.toISOString()]);
229
+ if (dbResult === null || dbResult.length === 0) {
230
+ PostgreSqlPersist.logEmptyAnswer('getActuatorHistory', dbResult, startDate, endDate);
231
+ return [];
232
+ }
233
+ const result = [];
234
+ let dropped = 0;
235
+ for (const entry of dbResult) {
236
+ // A row without a state says nothing; counting it as off would understate the run time.
237
+ const date = PostgreSqlPersist.fromNaiveTimestamp(entry.date);
238
+ if (entry.on === null || entry.on === undefined || date === undefined) {
239
+ dropped++;
240
+ continue;
241
+ }
242
+ result.push({ on: entry.on === true || entry.on === 'true' || entry.on === 't', date: date });
243
+ }
244
+ PostgreSqlPersist.logDroppedRows('getActuatorHistory', dropped);
106
245
  return result;
107
246
  }
108
247
  /** @inheritDoc */
248
+ async getWeatherDaySummaries(startDate, endDate) {
249
+ const dbResult = await this.query(`SELECT date, "cloudCover", "tempMin", "tempMax"
250
+ from hoffmation_schema."WeatherDaySummary"
251
+ WHERE date >= '${startDate.toISOString()}'
252
+ AND date <= '${endDate.toISOString()}'
253
+ ORDER BY DATE DESC`);
254
+ if (dbResult === null || dbResult.length === 0) {
255
+ PostgreSqlPersist.logEmptyAnswer('getWeatherDaySummaries', dbResult, startDate, endDate);
256
+ return [];
257
+ }
258
+ const result = [];
259
+ let dropped = 0;
260
+ for (const entry of dbResult) {
261
+ const cloudCover = PostgreSqlPersist.toFiniteNumber(entry.cloudCover);
262
+ const tempMin = PostgreSqlPersist.toFiniteNumber(entry.tempMin);
263
+ const tempMax = PostgreSqlPersist.toFiniteNumber(entry.tempMax);
264
+ // A half filled aggregate is dropped rather than completed: a substitute cloud cover would be fitted
265
+ // as if it had been measured.
266
+ const date = PostgreSqlPersist.fromNaiveTimestamp(entry.date);
267
+ if (cloudCover === undefined || tempMin === undefined || tempMax === undefined || date === undefined) {
268
+ dropped++;
269
+ continue;
270
+ }
271
+ result.push({ date: date, cloudCover: cloudCover, tempMin: tempMin, tempMax: tempMax });
272
+ }
273
+ PostgreSqlPersist.logDroppedRows('getWeatherDaySummaries', dropped);
274
+ return result;
275
+ }
276
+ /** @inheritDoc */
277
+ persistWeatherDaySummary(summary) {
278
+ // The values are bound, not written into the statement. Every other write in this file carries figures
279
+ // this process produced itself; these come from the weather service, and they are read back out of this
280
+ // table by a decision that switches an appliance. The fetcher already refuses what is not a number, but
281
+ // that check sits one caller away from being bypassed - so the statement itself is fixed text and the
282
+ // aggregate is data. The update half binds the same three placeholders: it is the one an interpolation
283
+ // is most easily left behind in.
284
+ this.query(`
285
+ insert into hoffmation_schema."WeatherDaySummary" ("date", "cloudCover", "tempMin", "tempMax")
286
+ values ($1, $2, $3, $4) ON CONFLICT ("date")
287
+ DO
288
+ UPDATE SET
289
+ "cloudCover" = $2,
290
+ "tempMin" = $3,
291
+ "tempMax" = $4
292
+ ;
293
+ `, [summary.date.toISOString(), summary.cloudCover, summary.tempMin, summary.tempMax]);
294
+ }
295
+ /** @inheritDoc */
109
296
  async initialize() {
110
297
  await this.psql.connect();
111
298
  // Execute BasicRoomsDDL
@@ -382,7 +569,20 @@ BEGIN
382
569
  );
383
570
 
384
571
  END IF;
385
-
572
+
573
+ IF (SELECT to_regclass('hoffmation_schema."WeatherDaySummary"') IS NULL) Then
574
+ create table hoffmation_schema."WeatherDaySummary"
575
+ (
576
+ date timestamp not null
577
+ constraint weatherdaysummary_pk
578
+ primary key,
579
+ "cloudCover" double precision,
580
+ "tempMin" double precision,
581
+ "tempMax" double precision
582
+ );
583
+
584
+ END IF;
585
+
386
586
  IF (SELECT COUNT(column_name) = 0
387
587
  FROM information_schema.columns
388
588
  WHERE table_name = 'EnergyCalculation'
@@ -462,10 +662,14 @@ $$;`);
462
662
  }
463
663
  /** @inheritDoc */
464
664
  persistSwitchInput(device, pressType, buttonName) {
665
+ // The button name is a text a person gave and the id is a text as well, so both are bound - an apostrophe
666
+ // in either closes the literal it would be pasted into. The press type is a number and stays this file's
667
+ // own text, and so does the timestamp: the column is `timestamp without time zone` and every recorded row
668
+ // was written as an interpolated `toISOString()`.
465
669
  this.query(`
466
670
  insert into hoffmation_schema."ButtonSwitchPresses" ("deviceID", "pressType", "buttonName", "date")
467
- values ('${device.id}', ${pressType}, '${buttonName}', '${new Date().toISOString()}');
468
- `);
671
+ values ($1, ${pressType}, $2, '${new Date().toISOString()}');
672
+ `, [device.id, buttonName]);
469
673
  }
470
674
  /** @inheritDoc */
471
675
  persistMotionSensor(device) {
@@ -548,36 +752,57 @@ $$;`);
548
752
  }
549
753
  /** @inheritDoc */
550
754
  persistSettings(id, settings, customName) {
755
+ // Of the four write paths that carry a text this is the widest: `settings` is a `JSON.stringify` of a
756
+ // whole settings object, so every value a person ever typed into any device setting arrives here. One
757
+ // apostrophe anywhere inside it closes the literal it would be pasted into - the statement fails, the
758
+ // failure is only a logged warning, and the setting is silently not stored until someone notices it gone
759
+ // after a restart. All three texts are bound, in both halves of the upsert; the blob keeps its own
760
+ // placeholder in the update half. The timestamp stays this file's own text: the column is `timestamp
761
+ // without time zone` and every recorded row was written as an interpolated `toISOString()`.
551
762
  this.query(`
552
763
  insert into hoffmation_schema."Settings" (id, settings, customname, date)
553
- values ('${id}', '${settings}', '${customName}', '${new Date().toISOString()}') ON CONFLICT (id, date)
764
+ values ($1, $2, $3, '${new Date().toISOString()}') ON CONFLICT (id, date)
554
765
  DO
555
766
  UPDATE SET
556
- settings = '${settings}',
557
- customname = '${customName}'
767
+ settings = $2,
768
+ customname = $3
558
769
  ;
559
- `);
770
+ `, [id, settings, customName]);
560
771
  }
561
772
  /** @inheritDoc */
562
773
  async loadSettings(id) {
774
+ // The id is bound rather than pasted in. Of the readers in this file it is the one whose argument does
775
+ // not come out of the device list: besides ObjectSettings.initializeFromDb, which passes a holder's id,
776
+ // ApiService.loadConfig hands through whatever its caller asks for - so this is a string from beyond
777
+ // this process, and a string inside quotes can close them.
563
778
  const dbResult = await this.query(`SELECT settings::text, id, date
564
779
  from hoffmation_schema."Settings"
565
- WHERE "id" = '${id}'
780
+ WHERE "id" = $1
566
781
  ORDER BY "date" DESC
567
- LIMIT 1`);
782
+ LIMIT 1`, [id]);
568
783
  if (dbResult !== null && dbResult.length > 0) {
569
784
  return dbResult[0].settings;
570
785
  }
571
786
  logging_1.ServerLogService.writeLog(enums_1.LogLevel.Info, `No persisted settings for ${id} found`);
572
787
  return undefined;
573
788
  }
574
- async query(query) {
789
+ /**
790
+ * Runs a statement, optionally with bind values.
791
+ *
792
+ * `values` is what makes a statement able to carry data that did not come from this process: the driver
793
+ * sends text and values apart, so a value is never parsed as SQL, whatever it spells. Anything that is not
794
+ * a literal fixed by this file belongs in there rather than in the string.
795
+ * @param query - The statement, with `$1..$n` where values are bound
796
+ * @param values - The values to bind, in the order of their placeholders
797
+ * @returns - The rows, or null when the statement did not come back
798
+ */
799
+ async query(query, values) {
575
800
  if (!this.isPsqlReady()) {
576
801
  return null;
577
802
  }
578
803
  return new Promise((resolve) => {
579
804
  this.psql
580
- .query(query)
805
+ .query(query, values)
581
806
  .then((result) => {
582
807
  resolve(result.rows);
583
808
  })
@@ -588,6 +813,51 @@ $$;`);
588
813
  });
589
814
  });
590
815
  }
816
+ /**
817
+ * A missing or unreadable history is a defined state of the history based decisions, not a failure - but it
818
+ * has to be visible once, otherwise "no answer" and "nothing recorded" look the same from the outside.
819
+ * @param reader - The reader that came up empty
820
+ * @param dbResult - null when the query itself did not come back, an empty set otherwise
821
+ * @param startDate - Start of the requested window
822
+ * @param endDate - End of the requested window
823
+ */
824
+ static logEmptyAnswer(reader, dbResult, startDate, endDate) {
825
+ logging_1.ServerLogService.writeLog(enums_1.LogLevel.Debug, `${reader}: ${dbResult === null ? 'no answer' : 'no rows'} for ${startDate.toISOString()} - ${endDate.toISOString()}`);
826
+ }
827
+ static logDroppedRows(reader, dropped) {
828
+ if (dropped === 0) {
829
+ return;
830
+ }
831
+ logging_1.ServerLogService.writeLog(enums_1.LogLevel.Debug, `${reader}: dropped ${dropped} unusable row(s)`);
832
+ }
833
+ /**
834
+ * Turns what the driver made of a `timestamp without time zone` back into the instant it was stored for.
835
+ *
836
+ * The write path stores `new Date(...).toISOString()`. For a naive column PostgreSQL silently drops the
837
+ * zone suffix of the literal and keeps the UTC wall clock. Reading it back, the driver rebuilds those
838
+ * components with the **local** multi argument Date constructor (`postgres-date/index.js:49-50`, registered
839
+ * for OID 1114 in `pg-types/lib/textParsers.js:175`), so the Date is off by the machine's zone offset - one
840
+ * hour or two in Berlin, and the offset changes inside a single 90 day window at the daylight saving
841
+ * switch. Reading the local components back out as UTC undoes exactly that.
842
+ *
843
+ * Only the read side compensates: `toISOString()` already stores the UTC wall clock and three years of
844
+ * recorded history are written that way, so changing the write path would put old and new rows at odds.
845
+ * @param value - The Date the driver produced, or null for an absent timestamp
846
+ * @returns - The instant, or undefined when there is none to be had
847
+ */
848
+ static fromNaiveTimestamp(value) {
849
+ if (value === null || isNaN(value.getTime())) {
850
+ return undefined;
851
+ }
852
+ return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate(), value.getHours(), value.getMinutes(), value.getSeconds(), value.getMilliseconds()));
853
+ }
854
+ static toFiniteNumber(value) {
855
+ if (value === null || value === undefined || value === '') {
856
+ return undefined;
857
+ }
858
+ const parsed = Number(value);
859
+ return isFinite(parsed) ? parsed : undefined;
860
+ }
591
861
  isPsqlReady() {
592
862
  if (!this.initialized) {
593
863
  logging_1.ServerLogService.writeLog(enums_1.LogLevel.Warn, 'Db is not yet initialized');
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The raw shape the driver hands back for one stored daily weather aggregate.
3
+ *
4
+ * Same conversion rules as {@link BatteryLevelRow}: numeric columns may arrive as a string, and the date is a
5
+ * Date built from naive components because the column carries no zone.
6
+ *
7
+ * Not part of the published surface - a database row shape is not an interface anyone implements.
8
+ */
9
+ export type WeatherDaySummaryRow = {
10
+ /** The day the aggregate belongs to, built from naive components */
11
+ date: Date | null;
12
+ /** Cloud cover of that day in percent, as the driver hands it back */
13
+ cloudCover: string | number | null;
14
+ /** Lowest air temperature of that day in degrees celsius */
15
+ tempMin: string | number | null;
16
+ /** Highest air temperature of that day in degrees celsius */
17
+ tempMax: string | number | null;
18
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });