hoffmation-base 4.0.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 (50) 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/device-cluster.js +7 -0
  6. package/lib/devices/devices.js +3 -0
  7. package/lib/devices/sharedFunctions/index.d.ts +1 -0
  8. package/lib/devices/sharedFunctions/index.js +1 -0
  9. package/lib/devices/sharedFunctions/soilSensor.d.ts +23 -0
  10. package/lib/devices/sharedFunctions/soilSensor.js +58 -0
  11. package/lib/devices/zigbee/BaseDevices/index.d.ts +1 -0
  12. package/lib/devices/zigbee/BaseDevices/index.js +1 -0
  13. package/lib/devices/zigbee/BaseDevices/zigbeeSoilSensor.d.ts +33 -0
  14. package/lib/devices/zigbee/BaseDevices/zigbeeSoilSensor.js +62 -0
  15. package/lib/devices/zigbee/index.d.ts +1 -0
  16. package/lib/devices/zigbee/index.js +1 -0
  17. package/lib/devices/zigbee/zigbeeCooloSoilSensor.d.ts +44 -0
  18. package/lib/devices/zigbee/zigbeeCooloSoilSensor.js +81 -0
  19. package/lib/enums/DeviceCapability.d.ts +1 -0
  20. package/lib/enums/DeviceCapability.js +1 -0
  21. package/lib/enums/commandType.d.ts +1 -0
  22. package/lib/enums/commandType.js +1 -0
  23. package/lib/enums/device-cluster-type.d.ts +2 -1
  24. package/lib/enums/device-cluster-type.js +1 -0
  25. package/lib/enums/deviceType.d.ts +1 -0
  26. package/lib/enums/deviceType.js +1 -0
  27. package/lib/interfaces/baseDevices/iSoilCollector.d.ts +23 -0
  28. package/lib/interfaces/baseDevices/iSoilCollector.js +2 -0
  29. package/lib/interfaces/baseDevices/iSoilSensor.d.ts +32 -0
  30. package/lib/interfaces/baseDevices/iSoilSensor.js +2 -0
  31. package/lib/interfaces/baseDevices/index.d.ts +3 -0
  32. package/lib/interfaces/baseDevices/index.js +3 -0
  33. package/lib/interfaces/baseDevices/undefinedSoilMoistureValue.d.ts +6 -0
  34. package/lib/interfaces/baseDevices/undefinedSoilMoistureValue.js +9 -0
  35. package/lib/interfaces/iPersist.d.ts +18 -1
  36. package/lib/interfaces/iSoilMoistureSample.d.ts +13 -0
  37. package/lib/interfaces/iSoilMoistureSample.js +2 -0
  38. package/lib/interfaces/iWeatherDaySummary.d.ts +12 -0
  39. package/lib/interfaces/index.d.ts +1 -0
  40. package/lib/interfaces/index.js +1 -0
  41. package/lib/services/dbo/postgreSqlPersist.d.ts +5 -1
  42. package/lib/services/dbo/postgreSqlPersist.js +102 -7
  43. package/lib/services/dbo/soil-moisture-row.d.ts +14 -0
  44. package/lib/services/dbo/soil-moisture-row.js +2 -0
  45. package/lib/services/dbo/weather-day-summary-row.d.ts +2 -0
  46. package/lib/services/weather/open-weather-day-summary.d.ts +4 -0
  47. package/lib/services/weather/weather-history-backfill.d.ts +6 -0
  48. package/lib/services/weather/weather-history-backfill.js +21 -2
  49. package/lib/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
- import { iAcDevice, iActuator, iAirQualityCollector, iBaseDevice, iBatteryDevice, iButtonSwitch, iHandle, iHeater, iHumidityCollector, iIlluminationSensor, iMotionSensor, iShutter, iTemperatureCollector, iZigbeeDevice } from './baseDevices';
1
+ import { iAcDevice, iActuator, iAirQualityCollector, iBaseDevice, iBatteryDevice, iButtonSwitch, iHandle, iHeater, iHumidityCollector, iIlluminationSensor, iMotionSensor, iShutter, iSoilCollector, iTemperatureCollector, iZigbeeDevice } from './baseDevices';
2
+ import { iSoilMoistureSample } from './iSoilMoistureSample';
2
3
  import { iTemperatureMeasurement } from './iTemperatureMeasurement';
3
4
  import { iRoomBase } from './iRoomBase';
4
5
  import { ButtonPressType } from '../enums';
@@ -169,6 +170,22 @@ export interface iPersist {
169
170
  * @param device - The device to persist data for
170
171
  */
171
172
  persistAirQualitySensor(device: iAirQualityCollector): void;
173
+ /**
174
+ * Persists data of a soil sensor
175
+ * @param device - The device to persist data for
176
+ */
177
+ persistSoilSensor(device: iSoilCollector): void;
178
+ /**
179
+ * Gets the recorded soil moisture readings of one sensor within the given window, in percent.
180
+ * An absent, unreachable or empty persistence is a defined state, not a failure: the answer is then an
181
+ * empty list. Readings without a usable value are dropped rather than replaced by a substitute value - a
182
+ * missing reading completed with 0 would read as bone dry soil and could ask for water that is not needed.
183
+ * @param deviceId - The ID of the device to load the readings for
184
+ * @param startDate - Start of the window (inclusive)
185
+ * @param endDate - End of the window (inclusive)
186
+ * @returns - The readings, newest first
187
+ */
188
+ getSoilMoistureHistory(deviceId: string, startDate: Date, endDate: Date): Promise<iSoilMoistureSample[]>;
172
189
  /**
173
190
  * Persists data of a handle sensor
174
191
  * @param device - The device to persist data for
@@ -0,0 +1,13 @@
1
+ /**
2
+ * One recorded soil moisture reading in the persistence layer.
3
+ */
4
+ export interface iSoilMoistureSample {
5
+ /**
6
+ * The measured soil moisture in percent
7
+ */
8
+ soilMoisture: number;
9
+ /**
10
+ * The date of the measurement
11
+ */
12
+ date: Date;
13
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -18,4 +18,16 @@ export interface iWeatherDaySummary {
18
18
  * The day's maximum temperature in degrees celsius
19
19
  */
20
20
  tempMax: number;
21
+ /**
22
+ * Total precipitation of the day in millimetres, or `undefined` when the day carries no reading for it.
23
+ *
24
+ * Optional on purpose, in two directions. Towards implementers: this aggregate is produced by whoever calls
25
+ * `persistWeatherDaySummary`, and a required field would stop existing code from compiling for a quantity
26
+ * the previous three consumers never asked for. Towards the record itself: the three fields above decide
27
+ * whether a day counts at all - a day whose cloud cover is missing is discarded, because a substitute would
28
+ * be fitted as if measured. Precipitation must not join that rule. A day without it is still a perfectly
29
+ * good day for the decisions that read cloud cover and temperature, and discarding it would tear holes into
30
+ * a history those decisions already depend on.
31
+ */
32
+ precipitation?: number;
21
33
  }
@@ -58,6 +58,7 @@ export * from './iProjectedSocBand';
58
58
  export * from './iMorningReserveVerdict';
59
59
  export * from './iDachsHistoryGateResult';
60
60
  export * from './iBatteryLevelSample';
61
+ export * from './iSoilMoistureSample';
61
62
  export * from './iActuatorStateSample';
62
63
  export * from './iWeatherDaySummary';
63
64
  export { iShutterCalibration } from './iShutterCalibration';
@@ -71,5 +71,6 @@ __exportStar(require("./iProjectedSocBand"), exports);
71
71
  __exportStar(require("./iMorningReserveVerdict"), exports);
72
72
  __exportStar(require("./iDachsHistoryGateResult"), exports);
73
73
  __exportStar(require("./iBatteryLevelSample"), exports);
74
+ __exportStar(require("./iSoilMoistureSample"), exports);
74
75
  __exportStar(require("./iActuatorStateSample"), exports);
75
76
  __exportStar(require("./iWeatherDaySummary"), exports);
@@ -1,5 +1,5 @@
1
1
  import { PoolConfig } from 'pg';
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';
2
+ import { iAcDevice, iActuator, iActuatorStateSample, iBaseDevice, iBatteryDevice, iBatteryLevelSample, iButtonSwitch, iConsumptionWindowSample, iDesiredShutterPosition, iHandle, iHeater, iAirQualityCollector, iHumidityCollector, iIlluminationSensor, iMotionSensor, iPersist, iRoomBase, iShutter, iShutterCalibration, iSoilCollector, iSoilMoistureSample, 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 {
@@ -54,6 +54,10 @@ export declare class PostgreSqlPersist implements iPersist {
54
54
  /** @inheritDoc */
55
55
  persistAirQualitySensor(device: iAirQualityCollector): void;
56
56
  /** @inheritDoc */
57
+ persistSoilSensor(device: iSoilCollector): void;
58
+ /** @inheritDoc */
59
+ getSoilMoistureHistory(deviceId: string, startDate: Date, endDate: Date): Promise<iSoilMoistureSample[]>;
60
+ /** @inheritDoc */
57
61
  persistBatteryDevice(device: iBatteryDevice): void;
58
62
  /** @inheritDoc */
59
63
  persistZigbeeDevice(device: iZigbeeDevice): void;
@@ -246,7 +246,7 @@ class PostgreSqlPersist {
246
246
  }
247
247
  /** @inheritDoc */
248
248
  async getWeatherDaySummaries(startDate, endDate) {
249
- const dbResult = await this.query(`SELECT date, "cloudCover", "tempMin", "tempMax"
249
+ const dbResult = await this.query(`SELECT date, "cloudCover", "tempMin", "tempMax", "precipitation"
250
250
  from hoffmation_schema."WeatherDaySummary"
251
251
  WHERE date >= '${startDate.toISOString()}'
252
252
  AND date <= '${endDate.toISOString()}'
@@ -268,13 +268,24 @@ class PostgreSqlPersist {
268
268
  dropped++;
269
269
  continue;
270
270
  }
271
- result.push({ date: date, cloudCover: cloudCover, tempMin: tempMin, tempMax: tempMax });
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);
275
+ result.push({
276
+ date: date,
277
+ cloudCover: cloudCover,
278
+ tempMin: tempMin,
279
+ tempMax: tempMax,
280
+ precipitation: precipitation,
281
+ });
272
282
  }
273
283
  PostgreSqlPersist.logDroppedRows('getWeatherDaySummaries', dropped);
274
284
  return result;
275
285
  }
276
286
  /** @inheritDoc */
277
287
  persistWeatherDaySummary(summary) {
288
+ var _a;
278
289
  // The values are bound, not written into the statement. Every other write in this file carries figures
279
290
  // this process produced itself; these come from the weather service, and they are read back out of this
280
291
  // table by a decision that switches an appliance. The fetcher already refuses what is not a number, but
@@ -282,15 +293,26 @@ class PostgreSqlPersist {
282
293
  // aggregate is data. The update half binds the same three placeholders: it is the one an interpolation
283
294
  // is most easily left behind in.
284
295
  this.query(`
285
- insert into hoffmation_schema."WeatherDaySummary" ("date", "cloudCover", "tempMin", "tempMax")
286
- values ($1, $2, $3, $4) ON CONFLICT ("date")
296
+ insert into hoffmation_schema."WeatherDaySummary" ("date", "cloudCover", "tempMin", "tempMax", "precipitation")
297
+ values ($1, $2, $3, $4, $5) ON CONFLICT ("date")
287
298
  DO
288
299
  UPDATE SET
289
300
  "cloudCover" = $2,
290
301
  "tempMin" = $3,
291
- "tempMax" = $4
302
+ "tempMax" = $4,
303
+ "precipitation" = COALESCE($5, hoffmation_schema."WeatherDaySummary"."precipitation")
292
304
  ;
293
- `, [summary.date.toISOString(), summary.cloudCover, summary.tempMin, summary.tempMax]);
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
+ ]);
294
316
  }
295
317
  /** @inheritDoc */
296
318
  async initialize() {
@@ -506,6 +528,21 @@ BEGIN
506
528
 
507
529
  END IF;
508
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
+
509
546
  IF (SELECT to_regclass('hoffmation_schema."BatteryDeviceData"') IS NULL) Then
510
547
  create table if not exists hoffmation_schema."BatteryDeviceData"
511
548
  (
@@ -578,11 +615,22 @@ BEGIN
578
615
  primary key,
579
616
  "cloudCover" double precision,
580
617
  "tempMin" double precision,
581
- "tempMax" double precision
618
+ "tempMax" double precision,
619
+ "precipitation" double precision
582
620
  );
583
621
 
584
622
  END IF;
585
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
+
586
634
  IF (SELECT COUNT(column_name) = 0
587
635
  FROM information_schema.columns
588
636
  WHERE table_name = 'EnergyCalculation'
@@ -715,6 +763,53 @@ $$;`);
715
763
  `);
716
764
  }
717
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 */
718
813
  persistBatteryDevice(device) {
719
814
  this.query(`
720
815
  insert into hoffmation_schema."BatteryDeviceData" ("deviceID", "battery", "date")
@@ -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 });
@@ -15,4 +15,6 @@ export type WeatherDaySummaryRow = {
15
15
  tempMin: string | number | null;
16
16
  /** Highest air temperature of that day in degrees celsius */
17
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;
18
20
  };
@@ -18,4 +18,8 @@ export type OpenWeatherDaySummary = {
18
18
  min?: unknown;
19
19
  max?: unknown;
20
20
  };
21
+ /** Precipitation of the day, as the endpoint reports it. Absent on an endpoint that does not send it */
22
+ precipitation?: {
23
+ total?: unknown;
24
+ };
21
25
  };
@@ -18,6 +18,12 @@ export declare class WeatherHistoryBackfill {
18
18
  */
19
19
  private static readonly minTemperature;
20
20
  private static readonly maxTemperature;
21
+ /**
22
+ * The most millimetres of precipitation a single day can carry. Wide on purpose, like the temperature band:
23
+ * it rejects what cannot be a daily rainfall at all, it does not second guess the weather service on a
24
+ * thunderstorm. The bound sits above the highest daily total ever recorded on the planet.
25
+ */
26
+ private static readonly maxPrecipitation;
21
27
  private static readonly host;
22
28
  /**
23
29
  * The past days of the window that were already fetched on the running day, and the running day that was
@@ -155,7 +155,7 @@ class WeatherHistoryBackfill {
155
155
  });
156
156
  }
157
157
  static parseDaySummary(response, statusCode, date, day) {
158
- var _a, _b, _c;
158
+ var _a, _b, _c, _d;
159
159
  if (statusCode !== 200) {
160
160
  // Neither the answer nor the request is logged: both carry the key and the location.
161
161
  logging_1.ServerLogService.writeLog(enums_1.LogLevel.Warn, `WeatherHistoryBackfill: day summary for ${day} answered ${statusCode}`);
@@ -174,9 +174,22 @@ class WeatherHistoryBackfill {
174
174
  logging_1.ServerLogService.writeLog(enums_1.LogLevel.Warn, `WeatherHistoryBackfill: incomplete day summary for ${day}`);
175
175
  return undefined;
176
176
  }
177
+ // Read AFTER the completeness check and deliberately outside it. The three fields above decide whether the
178
+ // day counts; precipitation must never be able to discard one. An endpoint that does not send the field,
179
+ // or a dry day it chooses to omit, would otherwise tear a hole into the history the start decision reads -
180
+ // a defect that would only show up as a gradually thinning window, weeks later. Absent stays absent all
181
+ // the way into the column; it is not filled with a 0, which would be an ordinary reading rather than a
182
+ // visibly missing one.
183
+ const precipitation = WeatherHistoryBackfill.plausibleReading((_d = parsed === null || parsed === void 0 ? void 0 : parsed.precipitation) === null || _d === void 0 ? void 0 : _d.total, 0, WeatherHistoryBackfill.maxPrecipitation);
177
184
  const dayStart = new Date(date);
178
185
  dayStart.setHours(0, 0, 0, 0);
179
- return { date: dayStart, cloudCover: cloudCover, tempMin: tempMin, tempMax: tempMax };
186
+ return {
187
+ date: dayStart,
188
+ cloudCover: cloudCover,
189
+ tempMin: tempMin,
190
+ tempMax: tempMax,
191
+ precipitation: precipitation,
192
+ };
180
193
  }
181
194
  /**
182
195
  * Establishes one field of the answer as a reading: a real number within the band its quantity can occupy.
@@ -212,6 +225,12 @@ WeatherHistoryBackfill.defaultThrottleMs = 1500;
212
225
  */
213
226
  WeatherHistoryBackfill.minTemperature = -95;
214
227
  WeatherHistoryBackfill.maxTemperature = 60;
228
+ /**
229
+ * The most millimetres of precipitation a single day can carry. Wide on purpose, like the temperature band:
230
+ * it rejects what cannot be a daily rainfall at all, it does not second guess the weather service on a
231
+ * thunderstorm. The bound sits above the highest daily total ever recorded on the planet.
232
+ */
233
+ WeatherHistoryBackfill.maxPrecipitation = 2000;
215
234
  WeatherHistoryBackfill.host = 'api.openweathermap.org';
216
235
  /**
217
236
  * The past days of the window that were already fetched on the running day, and the running day that was