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