hoffmation-base 3.8.0 → 4.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.
Files changed (160) hide show
  1. package/lib/action/index.d.ts +1 -0
  2. package/lib/action/index.js +3 -1
  3. package/lib/action/soilSensorChangeAction.d.ts +16 -0
  4. package/lib/action/soilSensorChangeAction.js +15 -0
  5. package/lib/devices/dachs/dachs.d.ts +79 -2
  6. package/lib/devices/dachs/dachs.js +214 -18
  7. package/lib/devices/device-cluster.js +7 -0
  8. package/lib/devices/devices.d.ts +36 -1
  9. package/lib/devices/devices.js +67 -0
  10. package/lib/devices/jsObject/jsObjectEnergyManager.d.ts +16 -1
  11. package/lib/devices/jsObject/jsObjectEnergyManager.js +23 -0
  12. package/lib/devices/sharedFunctions/index.d.ts +1 -0
  13. package/lib/devices/sharedFunctions/index.js +1 -0
  14. package/lib/devices/sharedFunctions/soilSensor.d.ts +23 -0
  15. package/lib/devices/sharedFunctions/soilSensor.js +58 -0
  16. package/lib/devices/victron/victron-device.d.ts +83 -1
  17. package/lib/devices/victron/victron-device.js +151 -2
  18. package/lib/devices/victron/victron-morning-release.d.ts +16 -0
  19. package/lib/devices/victron/victron-morning-release.js +2 -0
  20. package/lib/devices/zigbee/BaseDevices/index.d.ts +1 -0
  21. package/lib/devices/zigbee/BaseDevices/index.js +1 -0
  22. package/lib/devices/zigbee/BaseDevices/zigbeeSoilSensor.d.ts +33 -0
  23. package/lib/devices/zigbee/BaseDevices/zigbeeSoilSensor.js +62 -0
  24. package/lib/devices/zigbee/index.d.ts +1 -0
  25. package/lib/devices/zigbee/index.js +1 -0
  26. package/lib/devices/zigbee/zigbeeCooloSoilSensor.d.ts +44 -0
  27. package/lib/devices/zigbee/zigbeeCooloSoilSensor.js +81 -0
  28. package/lib/enums/DeviceCapability.d.ts +2 -0
  29. package/lib/enums/DeviceCapability.js +2 -0
  30. package/lib/enums/commandType.d.ts +1 -0
  31. package/lib/enums/commandType.js +1 -0
  32. package/lib/enums/device-cluster-type.d.ts +2 -1
  33. package/lib/enums/device-cluster-type.js +1 -0
  34. package/lib/enums/deviceType.d.ts +1 -0
  35. package/lib/enums/deviceType.js +1 -0
  36. package/lib/interfaces/baseDevices/iAirQualityCollector.d.ts +2 -1
  37. package/lib/interfaces/baseDevices/iAirQualityReadings.d.ts +50 -0
  38. package/lib/interfaces/baseDevices/iAirQualityReadings.js +2 -0
  39. package/lib/interfaces/baseDevices/iAirQualitySensor.d.ts +1 -54
  40. package/lib/interfaces/baseDevices/iAirQualitySensor.js +0 -5
  41. package/lib/interfaces/baseDevices/iEnergyManager.d.ts +43 -0
  42. package/lib/interfaces/baseDevices/iHumidityCollector.d.ts +0 -1
  43. package/lib/interfaces/baseDevices/iHumidityCollector.js +0 -2
  44. package/lib/interfaces/baseDevices/iSoilCollector.d.ts +23 -0
  45. package/lib/interfaces/baseDevices/iSoilCollector.js +2 -0
  46. package/lib/interfaces/baseDevices/iSoilSensor.d.ts +32 -0
  47. package/lib/interfaces/baseDevices/iSoilSensor.js +2 -0
  48. package/lib/interfaces/baseDevices/iTemperatureCollector.d.ts +0 -1
  49. package/lib/interfaces/baseDevices/iTemperatureCollector.js +0 -2
  50. package/lib/interfaces/baseDevices/index.d.ts +7 -0
  51. package/lib/interfaces/baseDevices/index.js +7 -0
  52. package/lib/interfaces/baseDevices/undefinedAirQualityValue.d.ts +4 -0
  53. package/lib/interfaces/baseDevices/undefinedAirQualityValue.js +7 -0
  54. package/lib/interfaces/baseDevices/undefinedHumidityValue.d.ts +4 -0
  55. package/lib/interfaces/baseDevices/undefinedHumidityValue.js +7 -0
  56. package/lib/interfaces/baseDevices/undefinedSoilMoistureValue.d.ts +6 -0
  57. package/lib/interfaces/baseDevices/undefinedSoilMoistureValue.js +9 -0
  58. package/lib/interfaces/baseDevices/undefinedTempValue.d.ts +4 -0
  59. package/lib/interfaces/baseDevices/undefinedTempValue.js +7 -0
  60. package/lib/interfaces/dachs/KeyListEntity.d.ts +0 -5
  61. package/lib/interfaces/dachs/KeyListEntityResponse.d.ts +6 -0
  62. package/lib/interfaces/dachs/KeyListEntityResponse.js +2 -0
  63. package/lib/interfaces/dachs/index.d.ts +1 -0
  64. package/lib/interfaces/dachs/index.js +1 -0
  65. package/lib/interfaces/deviceSettings/iDachsDeviceSettings.d.ts +15 -0
  66. package/lib/interfaces/iActuatorStateSample.d.ts +13 -0
  67. package/lib/interfaces/iActuatorStateSample.js +2 -0
  68. package/lib/interfaces/iBatteryLevelSample.d.ts +13 -0
  69. package/lib/interfaces/iBatteryLevelSample.js +2 -0
  70. package/lib/interfaces/iConsumptionWindowSample.d.ts +26 -0
  71. package/lib/interfaces/iConsumptionWindowSample.js +2 -0
  72. package/lib/interfaces/iDachsHistoryGateResult.d.ts +29 -0
  73. package/lib/interfaces/iDachsHistoryGateResult.js +2 -0
  74. package/lib/interfaces/iEnergyHistoryBasis.d.ts +26 -0
  75. package/lib/interfaces/iEnergyHistoryBasis.js +2 -0
  76. package/lib/interfaces/iEnergyHistoryEstimate.d.ts +21 -0
  77. package/lib/interfaces/iEnergyHistoryEstimate.js +2 -0
  78. package/lib/interfaces/iEnergyHistoryFeatures.d.ts +21 -0
  79. package/lib/interfaces/iEnergyHistoryFeatures.js +2 -0
  80. package/lib/interfaces/iEnergyHistoryModel.d.ts +21 -0
  81. package/lib/interfaces/iEnergyHistoryModel.js +2 -0
  82. package/lib/interfaces/iEnergyHistoryOptions.d.ts +29 -0
  83. package/lib/interfaces/iEnergyHistoryOptions.js +2 -0
  84. package/lib/interfaces/iEnergyHistoryOutlook.d.ts +41 -0
  85. package/lib/interfaces/iEnergyHistoryOutlook.js +2 -0
  86. package/lib/interfaces/iEnergyHistorySample.d.ts +19 -0
  87. package/lib/interfaces/iEnergyHistorySample.js +2 -0
  88. package/lib/interfaces/iFossilGeneratorRun.d.ts +22 -0
  89. package/lib/interfaces/iFossilGeneratorRun.js +2 -0
  90. package/lib/interfaces/iFossilGeneratorSource.d.ts +21 -0
  91. package/lib/interfaces/iFossilGeneratorSource.js +2 -0
  92. package/lib/interfaces/iMorningReserveVerdict.d.ts +38 -0
  93. package/lib/interfaces/iMorningReserveVerdict.js +2 -0
  94. package/lib/interfaces/iPersist.d.ts +69 -1
  95. package/lib/interfaces/iProjectedSocBand.d.ts +7 -0
  96. package/lib/interfaces/iProjectedSocBand.js +2 -0
  97. package/lib/interfaces/iSoilMoistureSample.d.ts +13 -0
  98. package/lib/interfaces/iSoilMoistureSample.js +2 -0
  99. package/lib/interfaces/iWeatherDaySummary.d.ts +33 -0
  100. package/lib/interfaces/iWeatherDaySummary.js +2 -0
  101. package/lib/interfaces/index.d.ts +17 -0
  102. package/lib/interfaces/index.js +17 -0
  103. package/lib/services/dbo/actuator-state-row.d.ts +14 -0
  104. package/lib/services/dbo/actuator-state-row.js +2 -0
  105. package/lib/services/dbo/battery-level-row.d.ts +16 -0
  106. package/lib/services/dbo/battery-level-row.js +2 -0
  107. package/lib/services/dbo/energy-consumption-row.d.ts +16 -0
  108. package/lib/services/dbo/energy-consumption-row.js +2 -0
  109. package/lib/services/dbo/postgreSqlPersist.d.ts +52 -1
  110. package/lib/services/dbo/postgreSqlPersist.js +392 -27
  111. package/lib/services/dbo/soil-moisture-row.d.ts +14 -0
  112. package/lib/services/dbo/soil-moisture-row.js +2 -0
  113. package/lib/services/dbo/weather-day-summary-row.d.ts +20 -0
  114. package/lib/services/dbo/weather-day-summary-row.js +2 -0
  115. package/lib/services/energy/energy-history-service.d.ts +168 -0
  116. package/lib/services/energy/energy-history-service.js +499 -0
  117. package/lib/services/energy/index.d.ts +1 -0
  118. package/lib/services/energy/index.js +17 -0
  119. package/lib/services/energy/plant-energy-dials.d.ts +22 -0
  120. package/lib/services/energy/plant-energy-dials.js +2 -0
  121. package/lib/services/https-service.d.ts +48 -2
  122. package/lib/services/https-service.js +136 -10
  123. package/lib/services/index.d.ts +1 -0
  124. package/lib/services/index.js +1 -0
  125. package/lib/services/time-callback-service.d.ts +12 -0
  126. package/lib/services/time-callback-service.js +16 -2
  127. package/lib/services/weather/index.d.ts +1 -0
  128. package/lib/services/weather/index.js +1 -0
  129. package/lib/services/weather/open-weather-day-summary.d.ts +25 -0
  130. package/lib/services/weather/open-weather-day-summary.js +2 -0
  131. package/lib/services/weather/weather-day-summary-fetcher.d.ts +6 -0
  132. package/lib/services/weather/weather-day-summary-fetcher.js +2 -0
  133. package/lib/services/weather/weather-history-backfill.d.ts +94 -0
  134. package/lib/services/weather/weather-history-backfill.js +247 -0
  135. package/lib/services/weather/weather-service.js +29 -4
  136. package/lib/settings-service-instance.js +5 -2
  137. package/lib/settingsObjects/deviceSettings/dachsSettings.d.ts +6 -0
  138. package/lib/settingsObjects/deviceSettings/dachsSettings.js +14 -1
  139. package/lib/settingsObjects/deviceSettings/victronDeviceSettings.d.ts +116 -0
  140. package/lib/settingsObjects/deviceSettings/victronDeviceSettings.js +150 -5
  141. package/lib/tsconfig.tsbuildinfo +1 -1
  142. package/lib/utils/dayMs.d.ts +7 -0
  143. package/lib/utils/dayMs.js +10 -0
  144. package/lib/utils/energy-history-dials.d.ts +19 -0
  145. package/lib/utils/energy-history-dials.js +2 -0
  146. package/lib/utils/energy-history-utils.d.ts +143 -0
  147. package/lib/utils/energy-history-utils.js +470 -0
  148. package/lib/utils/energy-manager-utils.d.ts +123 -1
  149. package/lib/utils/energy-manager-utils.js +226 -0
  150. package/lib/utils/index.d.ts +4 -0
  151. package/lib/utils/index.js +4 -0
  152. package/lib/utils/model-shadow.d.ts +61 -0
  153. package/lib/utils/model-shadow.js +83 -0
  154. package/lib/utils/morning-reserve-dials.d.ts +21 -0
  155. package/lib/utils/morning-reserve-dials.js +2 -0
  156. package/lib/utils/morning-verdict.d.ts +5 -0
  157. package/lib/utils/morning-verdict.js +2 -0
  158. package/lib/utils/utils.d.ts +0 -1
  159. package/lib/utils/utils.js +5 -5
  160. package/package.json +3 -2
@@ -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,220 @@ 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()}'
109
+ WHERE "deviceID" = $1
110
+ and date >= $2
111
+ AND date <= $3
112
+ ORDER BY DATE DESC`, [deviceId, start.toISOString(), end.toISOString()]);
113
+ if (dbResult === null || dbResult.length === 0) {
114
+ PostgreSqlPersist.logEmptyAnswer('getTemperatureHistory', dbResult, start, end);
115
+ return [];
116
+ }
117
+ const result = [];
118
+ let dropped = 0;
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
+ }
133
+ result.push({
134
+ temperature: temperature,
135
+ date: date,
136
+ });
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);
245
+ return result;
246
+ }
247
+ /** @inheritDoc */
248
+ async getWeatherDaySummaries(startDate, endDate) {
249
+ const dbResult = await this.query(`SELECT date, "cloudCover", "tempMin", "tempMax", "precipitation"
250
+ from hoffmation_schema."WeatherDaySummary"
251
+ WHERE date >= '${startDate.toISOString()}'
252
+ AND date <= '${endDate.toISOString()}'
95
253
  ORDER BY DATE DESC`);
96
254
  if (dbResult === null || dbResult.length === 0) {
255
+ PostgreSqlPersist.logEmptyAnswer('getWeatherDaySummaries', dbResult, startDate, endDate);
97
256
  return [];
98
257
  }
99
258
  const result = [];
259
+ let dropped = 0;
100
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
+ // Read after the completeness check and deliberately outside it, mirroring the write path: every row
272
+ // stored before this column existed carries a null here, and none of them may be dropped for it. An
273
+ // absent value stays absent rather than becoming a 0, which would read as a dry day.
274
+ const precipitation = PostgreSqlPersist.toFiniteNumber(entry.precipitation);
101
275
  result.push({
102
- temperature: Number(entry.temperature),
103
- date: new Date(entry.date),
276
+ date: date,
277
+ cloudCover: cloudCover,
278
+ tempMin: tempMin,
279
+ tempMax: tempMax,
280
+ precipitation: precipitation,
104
281
  });
105
282
  }
283
+ PostgreSqlPersist.logDroppedRows('getWeatherDaySummaries', dropped);
106
284
  return result;
107
285
  }
108
286
  /** @inheritDoc */
287
+ persistWeatherDaySummary(summary) {
288
+ var _a;
289
+ // The values are bound, not written into the statement. Every other write in this file carries figures
290
+ // this process produced itself; these come from the weather service, and they are read back out of this
291
+ // table by a decision that switches an appliance. The fetcher already refuses what is not a number, but
292
+ // that check sits one caller away from being bypassed - so the statement itself is fixed text and the
293
+ // aggregate is data. The update half binds the same three placeholders: it is the one an interpolation
294
+ // is most easily left behind in.
295
+ this.query(`
296
+ insert into hoffmation_schema."WeatherDaySummary" ("date", "cloudCover", "tempMin", "tempMax", "precipitation")
297
+ values ($1, $2, $3, $4, $5) ON CONFLICT ("date")
298
+ DO
299
+ UPDATE SET
300
+ "cloudCover" = $2,
301
+ "tempMin" = $3,
302
+ "tempMax" = $4,
303
+ "precipitation" = COALESCE($5, hoffmation_schema."WeatherDaySummary"."precipitation")
304
+ ;
305
+ `, [
306
+ summary.date.toISOString(),
307
+ summary.cloudCover,
308
+ summary.tempMin,
309
+ summary.tempMax,
310
+ // Undefined is bound as null on purpose - the column has to be able to say "not recorded", and a 0
311
+ // would say "it did not rain". The three columns above are overwritten unconditionally because the
312
+ // running day's forecast moves; this one keeps what is already stored when nothing new arrived, so a
313
+ // refetch that comes back without the field cannot erase a figure an earlier one delivered.
314
+ (_a = summary.precipitation) !== null && _a !== void 0 ? _a : null,
315
+ ]);
316
+ }
317
+ /** @inheritDoc */
109
318
  async initialize() {
110
319
  await this.psql.connect();
111
320
  // Execute BasicRoomsDDL
@@ -319,6 +528,21 @@ BEGIN
319
528
 
320
529
  END IF;
321
530
 
531
+ IF (SELECT to_regclass('hoffmation_schema."SoilSensorDeviceData"') IS NULL) Then
532
+ -- Deliberately without a foreign key on "DeviceInfo", for the same reason as the air quality table above:
533
+ -- creating one needs the REFERENCES privilege on that table, which an existing installation whose tables
534
+ -- were created by another role may not grant.
535
+ create table if not exists hoffmation_schema."SoilSensorDeviceData"
536
+ (
537
+ "deviceID" varchar(60) not null,
538
+ "soilMoisture" double precision,
539
+ date timestamp not null,
540
+ constraint soilsensordevicedata_pk
541
+ primary key ("deviceID", date)
542
+ );
543
+
544
+ END IF;
545
+
322
546
  IF (SELECT to_regclass('hoffmation_schema."BatteryDeviceData"') IS NULL) Then
323
547
  create table if not exists hoffmation_schema."BatteryDeviceData"
324
548
  (
@@ -382,7 +606,31 @@ BEGIN
382
606
  );
383
607
 
384
608
  END IF;
385
-
609
+
610
+ IF (SELECT to_regclass('hoffmation_schema."WeatherDaySummary"') IS NULL) Then
611
+ create table hoffmation_schema."WeatherDaySummary"
612
+ (
613
+ date timestamp not null
614
+ constraint weatherdaysummary_pk
615
+ primary key,
616
+ "cloudCover" double precision,
617
+ "tempMin" double precision,
618
+ "tempMax" double precision,
619
+ "precipitation" double precision
620
+ );
621
+
622
+ END IF;
623
+
624
+ IF (SELECT COUNT(column_name) = 0
625
+ FROM information_schema.columns
626
+ WHERE table_name = 'WeatherDaySummary'
627
+ and column_name = 'precipitation') Then
628
+ -- The table predates this column, so an existing installation gets it added rather than created. Existing
629
+ -- rows keep a null, which is the honest answer: nothing was recorded for those days.
630
+ alter table hoffmation_schema."WeatherDaySummary"
631
+ add "precipitation" double precision;
632
+ END IF;
633
+
386
634
  IF (SELECT COUNT(column_name) = 0
387
635
  FROM information_schema.columns
388
636
  WHERE table_name = 'EnergyCalculation'
@@ -462,10 +710,14 @@ $$;`);
462
710
  }
463
711
  /** @inheritDoc */
464
712
  persistSwitchInput(device, pressType, buttonName) {
713
+ // The button name is a text a person gave and the id is a text as well, so both are bound - an apostrophe
714
+ // in either closes the literal it would be pasted into. The press type is a number and stays this file's
715
+ // own text, and so does the timestamp: the column is `timestamp without time zone` and every recorded row
716
+ // was written as an interpolated `toISOString()`.
465
717
  this.query(`
466
718
  insert into hoffmation_schema."ButtonSwitchPresses" ("deviceID", "pressType", "buttonName", "date")
467
- values ('${device.id}', ${pressType}, '${buttonName}', '${new Date().toISOString()}');
468
- `);
719
+ values ($1, ${pressType}, $2, '${new Date().toISOString()}');
720
+ `, [device.id, buttonName]);
469
721
  }
470
722
  /** @inheritDoc */
471
723
  persistMotionSensor(device) {
@@ -511,6 +763,53 @@ $$;`);
511
763
  `);
512
764
  }
513
765
  /** @inheritDoc */
766
+ persistSoilSensor(device) {
767
+ this.query(`
768
+ insert into hoffmation_schema."SoilSensorDeviceData" ("deviceID", "soilMoisture", "date")
769
+ values ('${device.id}', ${device.soilMoisture}, '${new Date().toISOString()}');
770
+ `);
771
+ }
772
+ /** @inheritDoc */
773
+ async getSoilMoistureHistory(deviceId, startDate, endDate) {
774
+ // The device id is bound rather than pasted in: it is a string this reader is handed, and a string inside
775
+ // quotes can close them. The dates travel the same way for consistency - bound as ISO text, which is what
776
+ // the naive column stores and hence the same comparison the statement makes with a literal.
777
+ const dbResult = await this.query(`SELECT "soilMoisture", date
778
+ from hoffmation_schema."SoilSensorDeviceData"
779
+ WHERE "deviceID" = $1
780
+ and date >= $2
781
+ AND date <= $3
782
+ ORDER BY DATE DESC`, [deviceId, startDate.toISOString(), endDate.toISOString()]);
783
+ if (dbResult === null || dbResult.length === 0) {
784
+ PostgreSqlPersist.logEmptyAnswer('getSoilMoistureHistory', dbResult, startDate, endDate);
785
+ return [];
786
+ }
787
+ const result = [];
788
+ let dropped = 0;
789
+ for (const entry of dbResult) {
790
+ // An absent value read as 0 would read as bone dry soil - the one reading that makes a watering
791
+ // decision act. A genuine 0 % survives; only an absent or unreadable one is dropped.
792
+ const soilMoisture = PostgreSqlPersist.toFiniteNumber(entry.soilMoisture);
793
+ // A reading without a timestamp cannot be placed: read as a Date an absent one lands on 1970-01-01,
794
+ // which looks like a reading at the far edge of the window rather than like a missing one.
795
+ const date = PostgreSqlPersist.fromNaiveTimestamp(entry.date);
796
+ if (soilMoisture === undefined || date === undefined) {
797
+ dropped++;
798
+ continue;
799
+ }
800
+ // Outside 0..100 this is not a soil moisture. The sentinel the sensor carries before its first reading
801
+ // is -1, and the write path already refuses to store it - this is the second line of defence, for rows
802
+ // an older version may have written.
803
+ if (soilMoisture < 0 || soilMoisture > 100) {
804
+ dropped++;
805
+ continue;
806
+ }
807
+ result.push({ soilMoisture: soilMoisture, date: date });
808
+ }
809
+ PostgreSqlPersist.logDroppedRows('getSoilMoistureHistory', dropped);
810
+ return result;
811
+ }
812
+ /** @inheritDoc */
514
813
  persistBatteryDevice(device) {
515
814
  this.query(`
516
815
  insert into hoffmation_schema."BatteryDeviceData" ("deviceID", "battery", "date")
@@ -548,36 +847,57 @@ $$;`);
548
847
  }
549
848
  /** @inheritDoc */
550
849
  persistSettings(id, settings, customName) {
850
+ // Of the four write paths that carry a text this is the widest: `settings` is a `JSON.stringify` of a
851
+ // whole settings object, so every value a person ever typed into any device setting arrives here. One
852
+ // apostrophe anywhere inside it closes the literal it would be pasted into - the statement fails, the
853
+ // failure is only a logged warning, and the setting is silently not stored until someone notices it gone
854
+ // after a restart. All three texts are bound, in both halves of the upsert; the blob keeps its own
855
+ // placeholder in the update half. The timestamp stays this file's own text: the column is `timestamp
856
+ // without time zone` and every recorded row was written as an interpolated `toISOString()`.
551
857
  this.query(`
552
858
  insert into hoffmation_schema."Settings" (id, settings, customname, date)
553
- values ('${id}', '${settings}', '${customName}', '${new Date().toISOString()}') ON CONFLICT (id, date)
859
+ values ($1, $2, $3, '${new Date().toISOString()}') ON CONFLICT (id, date)
554
860
  DO
555
861
  UPDATE SET
556
- settings = '${settings}',
557
- customname = '${customName}'
862
+ settings = $2,
863
+ customname = $3
558
864
  ;
559
- `);
865
+ `, [id, settings, customName]);
560
866
  }
561
867
  /** @inheritDoc */
562
868
  async loadSettings(id) {
869
+ // The id is bound rather than pasted in. Of the readers in this file it is the one whose argument does
870
+ // not come out of the device list: besides ObjectSettings.initializeFromDb, which passes a holder's id,
871
+ // ApiService.loadConfig hands through whatever its caller asks for - so this is a string from beyond
872
+ // this process, and a string inside quotes can close them.
563
873
  const dbResult = await this.query(`SELECT settings::text, id, date
564
874
  from hoffmation_schema."Settings"
565
- WHERE "id" = '${id}'
875
+ WHERE "id" = $1
566
876
  ORDER BY "date" DESC
567
- LIMIT 1`);
877
+ LIMIT 1`, [id]);
568
878
  if (dbResult !== null && dbResult.length > 0) {
569
879
  return dbResult[0].settings;
570
880
  }
571
881
  logging_1.ServerLogService.writeLog(enums_1.LogLevel.Info, `No persisted settings for ${id} found`);
572
882
  return undefined;
573
883
  }
574
- async query(query) {
884
+ /**
885
+ * Runs a statement, optionally with bind values.
886
+ *
887
+ * `values` is what makes a statement able to carry data that did not come from this process: the driver
888
+ * sends text and values apart, so a value is never parsed as SQL, whatever it spells. Anything that is not
889
+ * a literal fixed by this file belongs in there rather than in the string.
890
+ * @param query - The statement, with `$1..$n` where values are bound
891
+ * @param values - The values to bind, in the order of their placeholders
892
+ * @returns - The rows, or null when the statement did not come back
893
+ */
894
+ async query(query, values) {
575
895
  if (!this.isPsqlReady()) {
576
896
  return null;
577
897
  }
578
898
  return new Promise((resolve) => {
579
899
  this.psql
580
- .query(query)
900
+ .query(query, values)
581
901
  .then((result) => {
582
902
  resolve(result.rows);
583
903
  })
@@ -588,6 +908,51 @@ $$;`);
588
908
  });
589
909
  });
590
910
  }
911
+ /**
912
+ * A missing or unreadable history is a defined state of the history based decisions, not a failure - but it
913
+ * has to be visible once, otherwise "no answer" and "nothing recorded" look the same from the outside.
914
+ * @param reader - The reader that came up empty
915
+ * @param dbResult - null when the query itself did not come back, an empty set otherwise
916
+ * @param startDate - Start of the requested window
917
+ * @param endDate - End of the requested window
918
+ */
919
+ static logEmptyAnswer(reader, dbResult, startDate, endDate) {
920
+ logging_1.ServerLogService.writeLog(enums_1.LogLevel.Debug, `${reader}: ${dbResult === null ? 'no answer' : 'no rows'} for ${startDate.toISOString()} - ${endDate.toISOString()}`);
921
+ }
922
+ static logDroppedRows(reader, dropped) {
923
+ if (dropped === 0) {
924
+ return;
925
+ }
926
+ logging_1.ServerLogService.writeLog(enums_1.LogLevel.Debug, `${reader}: dropped ${dropped} unusable row(s)`);
927
+ }
928
+ /**
929
+ * Turns what the driver made of a `timestamp without time zone` back into the instant it was stored for.
930
+ *
931
+ * The write path stores `new Date(...).toISOString()`. For a naive column PostgreSQL silently drops the
932
+ * zone suffix of the literal and keeps the UTC wall clock. Reading it back, the driver rebuilds those
933
+ * components with the **local** multi argument Date constructor (`postgres-date/index.js:49-50`, registered
934
+ * for OID 1114 in `pg-types/lib/textParsers.js:175`), so the Date is off by the machine's zone offset - one
935
+ * hour or two in Berlin, and the offset changes inside a single 90 day window at the daylight saving
936
+ * switch. Reading the local components back out as UTC undoes exactly that.
937
+ *
938
+ * Only the read side compensates: `toISOString()` already stores the UTC wall clock and three years of
939
+ * recorded history are written that way, so changing the write path would put old and new rows at odds.
940
+ * @param value - The Date the driver produced, or null for an absent timestamp
941
+ * @returns - The instant, or undefined when there is none to be had
942
+ */
943
+ static fromNaiveTimestamp(value) {
944
+ if (value === null || isNaN(value.getTime())) {
945
+ return undefined;
946
+ }
947
+ return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate(), value.getHours(), value.getMinutes(), value.getSeconds(), value.getMilliseconds()));
948
+ }
949
+ static toFiniteNumber(value) {
950
+ if (value === null || value === undefined || value === '') {
951
+ return undefined;
952
+ }
953
+ const parsed = Number(value);
954
+ return isFinite(parsed) ? parsed : undefined;
955
+ }
591
956
  isPsqlReady() {
592
957
  if (!this.initialized) {
593
958
  logging_1.ServerLogService.writeLog(enums_1.LogLevel.Warn, 'Db is not yet initialized');
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The raw shape the driver hands back for one stored soil moisture reading.
3
+ *
4
+ * Same conversion rules as {@link BatteryLevelRow}: the numeric column may arrive as a string, and the date is
5
+ * 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 SoilMoistureRow = {
10
+ /** The stored soil moisture in percent, as the driver hands it back */
11
+ soilMoisture: string | number | null;
12
+ /** When the reading was taken, built from naive components */
13
+ date: Date | null;
14
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,20 @@
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
+ /** Total precipitation of that day in millimetres. Null for every row written before the column existed */
19
+ precipitation: string | number | null;
20
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });