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.
- package/lib/action/index.d.ts +1 -0
- package/lib/action/index.js +3 -1
- package/lib/action/soilSensorChangeAction.d.ts +16 -0
- package/lib/action/soilSensorChangeAction.js +15 -0
- package/lib/devices/dachs/dachs.d.ts +79 -2
- package/lib/devices/dachs/dachs.js +214 -18
- package/lib/devices/device-cluster.js +7 -0
- package/lib/devices/devices.d.ts +36 -1
- package/lib/devices/devices.js +67 -0
- package/lib/devices/jsObject/jsObjectEnergyManager.d.ts +16 -1
- package/lib/devices/jsObject/jsObjectEnergyManager.js +23 -0
- package/lib/devices/sharedFunctions/index.d.ts +1 -0
- package/lib/devices/sharedFunctions/index.js +1 -0
- package/lib/devices/sharedFunctions/soilSensor.d.ts +23 -0
- package/lib/devices/sharedFunctions/soilSensor.js +58 -0
- package/lib/devices/victron/victron-device.d.ts +83 -1
- package/lib/devices/victron/victron-device.js +151 -2
- package/lib/devices/victron/victron-morning-release.d.ts +16 -0
- package/lib/devices/victron/victron-morning-release.js +2 -0
- package/lib/devices/zigbee/BaseDevices/index.d.ts +1 -0
- package/lib/devices/zigbee/BaseDevices/index.js +1 -0
- package/lib/devices/zigbee/BaseDevices/zigbeeSoilSensor.d.ts +33 -0
- package/lib/devices/zigbee/BaseDevices/zigbeeSoilSensor.js +62 -0
- package/lib/devices/zigbee/index.d.ts +1 -0
- package/lib/devices/zigbee/index.js +1 -0
- package/lib/devices/zigbee/zigbeeCooloSoilSensor.d.ts +44 -0
- package/lib/devices/zigbee/zigbeeCooloSoilSensor.js +81 -0
- package/lib/enums/DeviceCapability.d.ts +2 -0
- package/lib/enums/DeviceCapability.js +2 -0
- package/lib/enums/commandType.d.ts +1 -0
- package/lib/enums/commandType.js +1 -0
- package/lib/enums/device-cluster-type.d.ts +2 -1
- package/lib/enums/device-cluster-type.js +1 -0
- package/lib/enums/deviceType.d.ts +1 -0
- package/lib/enums/deviceType.js +1 -0
- package/lib/interfaces/baseDevices/iAirQualityCollector.d.ts +2 -1
- package/lib/interfaces/baseDevices/iAirQualityReadings.d.ts +50 -0
- package/lib/interfaces/baseDevices/iAirQualityReadings.js +2 -0
- package/lib/interfaces/baseDevices/iAirQualitySensor.d.ts +1 -54
- package/lib/interfaces/baseDevices/iAirQualitySensor.js +0 -5
- package/lib/interfaces/baseDevices/iEnergyManager.d.ts +43 -0
- package/lib/interfaces/baseDevices/iHumidityCollector.d.ts +0 -1
- package/lib/interfaces/baseDevices/iHumidityCollector.js +0 -2
- package/lib/interfaces/baseDevices/iSoilCollector.d.ts +23 -0
- package/lib/interfaces/baseDevices/iSoilCollector.js +2 -0
- package/lib/interfaces/baseDevices/iSoilSensor.d.ts +32 -0
- package/lib/interfaces/baseDevices/iSoilSensor.js +2 -0
- package/lib/interfaces/baseDevices/iTemperatureCollector.d.ts +0 -1
- package/lib/interfaces/baseDevices/iTemperatureCollector.js +0 -2
- package/lib/interfaces/baseDevices/index.d.ts +7 -0
- package/lib/interfaces/baseDevices/index.js +7 -0
- package/lib/interfaces/baseDevices/undefinedAirQualityValue.d.ts +4 -0
- package/lib/interfaces/baseDevices/undefinedAirQualityValue.js +7 -0
- package/lib/interfaces/baseDevices/undefinedHumidityValue.d.ts +4 -0
- package/lib/interfaces/baseDevices/undefinedHumidityValue.js +7 -0
- package/lib/interfaces/baseDevices/undefinedSoilMoistureValue.d.ts +6 -0
- package/lib/interfaces/baseDevices/undefinedSoilMoistureValue.js +9 -0
- package/lib/interfaces/baseDevices/undefinedTempValue.d.ts +4 -0
- package/lib/interfaces/baseDevices/undefinedTempValue.js +7 -0
- package/lib/interfaces/dachs/KeyListEntity.d.ts +0 -5
- package/lib/interfaces/dachs/KeyListEntityResponse.d.ts +6 -0
- package/lib/interfaces/dachs/KeyListEntityResponse.js +2 -0
- package/lib/interfaces/dachs/index.d.ts +1 -0
- package/lib/interfaces/dachs/index.js +1 -0
- package/lib/interfaces/deviceSettings/iDachsDeviceSettings.d.ts +15 -0
- package/lib/interfaces/iActuatorStateSample.d.ts +13 -0
- package/lib/interfaces/iActuatorStateSample.js +2 -0
- package/lib/interfaces/iBatteryLevelSample.d.ts +13 -0
- package/lib/interfaces/iBatteryLevelSample.js +2 -0
- package/lib/interfaces/iConsumptionWindowSample.d.ts +26 -0
- package/lib/interfaces/iConsumptionWindowSample.js +2 -0
- package/lib/interfaces/iDachsHistoryGateResult.d.ts +29 -0
- package/lib/interfaces/iDachsHistoryGateResult.js +2 -0
- package/lib/interfaces/iEnergyHistoryBasis.d.ts +26 -0
- package/lib/interfaces/iEnergyHistoryBasis.js +2 -0
- package/lib/interfaces/iEnergyHistoryEstimate.d.ts +21 -0
- package/lib/interfaces/iEnergyHistoryEstimate.js +2 -0
- package/lib/interfaces/iEnergyHistoryFeatures.d.ts +21 -0
- package/lib/interfaces/iEnergyHistoryFeatures.js +2 -0
- package/lib/interfaces/iEnergyHistoryModel.d.ts +21 -0
- package/lib/interfaces/iEnergyHistoryModel.js +2 -0
- package/lib/interfaces/iEnergyHistoryOptions.d.ts +29 -0
- package/lib/interfaces/iEnergyHistoryOptions.js +2 -0
- package/lib/interfaces/iEnergyHistoryOutlook.d.ts +41 -0
- package/lib/interfaces/iEnergyHistoryOutlook.js +2 -0
- package/lib/interfaces/iEnergyHistorySample.d.ts +19 -0
- package/lib/interfaces/iEnergyHistorySample.js +2 -0
- package/lib/interfaces/iFossilGeneratorRun.d.ts +22 -0
- package/lib/interfaces/iFossilGeneratorRun.js +2 -0
- package/lib/interfaces/iFossilGeneratorSource.d.ts +21 -0
- package/lib/interfaces/iFossilGeneratorSource.js +2 -0
- package/lib/interfaces/iMorningReserveVerdict.d.ts +38 -0
- package/lib/interfaces/iMorningReserveVerdict.js +2 -0
- package/lib/interfaces/iPersist.d.ts +69 -1
- package/lib/interfaces/iProjectedSocBand.d.ts +7 -0
- package/lib/interfaces/iProjectedSocBand.js +2 -0
- package/lib/interfaces/iSoilMoistureSample.d.ts +13 -0
- package/lib/interfaces/iSoilMoistureSample.js +2 -0
- package/lib/interfaces/iWeatherDaySummary.d.ts +33 -0
- package/lib/interfaces/iWeatherDaySummary.js +2 -0
- package/lib/interfaces/index.d.ts +17 -0
- package/lib/interfaces/index.js +17 -0
- package/lib/services/dbo/actuator-state-row.d.ts +14 -0
- package/lib/services/dbo/actuator-state-row.js +2 -0
- package/lib/services/dbo/battery-level-row.d.ts +16 -0
- package/lib/services/dbo/battery-level-row.js +2 -0
- package/lib/services/dbo/energy-consumption-row.d.ts +16 -0
- package/lib/services/dbo/energy-consumption-row.js +2 -0
- package/lib/services/dbo/postgreSqlPersist.d.ts +52 -1
- package/lib/services/dbo/postgreSqlPersist.js +392 -27
- package/lib/services/dbo/soil-moisture-row.d.ts +14 -0
- package/lib/services/dbo/soil-moisture-row.js +2 -0
- package/lib/services/dbo/weather-day-summary-row.d.ts +20 -0
- package/lib/services/dbo/weather-day-summary-row.js +2 -0
- package/lib/services/energy/energy-history-service.d.ts +168 -0
- package/lib/services/energy/energy-history-service.js +499 -0
- package/lib/services/energy/index.d.ts +1 -0
- package/lib/services/energy/index.js +17 -0
- package/lib/services/energy/plant-energy-dials.d.ts +22 -0
- package/lib/services/energy/plant-energy-dials.js +2 -0
- package/lib/services/https-service.d.ts +48 -2
- package/lib/services/https-service.js +136 -10
- package/lib/services/index.d.ts +1 -0
- package/lib/services/index.js +1 -0
- package/lib/services/time-callback-service.d.ts +12 -0
- package/lib/services/time-callback-service.js +16 -2
- package/lib/services/weather/index.d.ts +1 -0
- package/lib/services/weather/index.js +1 -0
- package/lib/services/weather/open-weather-day-summary.d.ts +25 -0
- package/lib/services/weather/open-weather-day-summary.js +2 -0
- package/lib/services/weather/weather-day-summary-fetcher.d.ts +6 -0
- package/lib/services/weather/weather-day-summary-fetcher.js +2 -0
- package/lib/services/weather/weather-history-backfill.d.ts +94 -0
- package/lib/services/weather/weather-history-backfill.js +247 -0
- package/lib/services/weather/weather-service.js +29 -4
- package/lib/settings-service-instance.js +5 -2
- package/lib/settingsObjects/deviceSettings/dachsSettings.d.ts +6 -0
- package/lib/settingsObjects/deviceSettings/dachsSettings.js +14 -1
- package/lib/settingsObjects/deviceSettings/victronDeviceSettings.d.ts +116 -0
- package/lib/settingsObjects/deviceSettings/victronDeviceSettings.js +150 -5
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/utils/dayMs.d.ts +7 -0
- package/lib/utils/dayMs.js +10 -0
- package/lib/utils/energy-history-dials.d.ts +19 -0
- package/lib/utils/energy-history-dials.js +2 -0
- package/lib/utils/energy-history-utils.d.ts +143 -0
- package/lib/utils/energy-history-utils.js +470 -0
- package/lib/utils/energy-manager-utils.d.ts +123 -1
- package/lib/utils/energy-manager-utils.js +226 -0
- package/lib/utils/index.d.ts +4 -0
- package/lib/utils/index.js +4 -0
- package/lib/utils/model-shadow.d.ts +61 -0
- package/lib/utils/model-shadow.js +83 -0
- package/lib/utils/morning-reserve-dials.d.ts +21 -0
- package/lib/utils/morning-reserve-dials.js +2 -0
- package/lib/utils/morning-verdict.d.ts +5 -0
- package/lib/utils/morning-verdict.js +2 -0
- package/lib/utils/utils.d.ts +0 -1
- package/lib/utils/utils.js +5 -5
- 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 (
|
|
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 (
|
|
41
|
+
values ($1, $2, $3, $4,
|
|
33
42
|
${device.deviceType}) ON CONFLICT ("deviceid")
|
|
34
43
|
DO
|
|
35
44
|
UPDATE SET
|
|
36
|
-
"roomname" =
|
|
37
|
-
"alldeviceskey" =
|
|
38
|
-
"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" =
|
|
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" =
|
|
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" =
|
|
93
|
-
and date >=
|
|
94
|
-
AND date <=
|
|
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
|
-
|
|
103
|
-
|
|
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 (
|
|
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 (
|
|
859
|
+
values ($1, $2, $3, '${new Date().toISOString()}') ON CONFLICT (id, date)
|
|
554
860
|
DO
|
|
555
861
|
UPDATE SET
|
|
556
|
-
settings =
|
|
557
|
-
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" =
|
|
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
|
-
|
|
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,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
|
+
};
|