homebridge-nest-accfactory 0.3.0 → 0.3.2

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.
@@ -6,22 +6,22 @@
6
6
 
7
7
  // Define our modules
8
8
  import HomeKitDevice from '../HomeKitDevice.js';
9
+ import { processCommonData, adjustTemperature, crc24 } from '../utils.js';
10
+
11
+ // Define constants
12
+ import { DATA_SOURCE, DEVICE_TYPE, NESTLABS_MAC_PREFIX } from '../consts.js';
9
13
 
10
14
  export default class NestWeather extends HomeKitDevice {
11
15
  static TYPE = 'Weather';
12
- static VERSION = '2025.06.11';
16
+ static VERSION = '2025.08.04'; // Code version
13
17
 
14
18
  batteryService = undefined;
15
19
  airPressureService = undefined;
16
20
  temperatureService = undefined;
17
21
  humidityService = undefined;
18
22
 
19
- constructor(accessory, api, log, eventEmitter, deviceData) {
20
- super(accessory, api, log, eventEmitter, deviceData);
21
- }
22
-
23
23
  // Class functions
24
- setupDevice() {
24
+ onAdd() {
25
25
  // Setup temperature service if not already present on the accessory
26
26
  this.temperatureService = this.addHKService(this.hap.Service.TemperatureSensor, '', 1);
27
27
  this.temperatureService.setPrimaryService();
@@ -29,13 +29,14 @@ export default class NestWeather extends HomeKitDevice {
29
29
  // Setup humidity service if not already present on the accessory
30
30
  this.humidityService = this.addHKService(this.hap.Service.HumiditySensor, '', 1);
31
31
 
32
- // Setup battery service if not already present on the accessory
32
+ // Setup battery service if not already present on the accessory (required for EveHome support)
33
33
  this.batteryService = this.addHKService(this.hap.Service.Battery, '', 1);
34
34
  this.batteryService.setHiddenService(true);
35
35
 
36
36
  // Add custom weather service and characteristics if they have been defined
37
37
  if (this.hap.Service?.EveAirPressureSensor !== undefined) {
38
- this.airPressureService = this.addHKService(this.hap.Service.EveAirPressureSensor, '', 1);
38
+ // This will be linked to the Eve app if configured to do so
39
+ this.airPressureService = this.addHKService(this.hap.Service.EveAirPressureSensor, '', 1, {});
39
40
  }
40
41
 
41
42
  if (this.hap.Characteristic?.ForecastDay !== undefined) {
@@ -60,22 +61,22 @@ export default class NestWeather extends HomeKitDevice {
60
61
  this.addHKCharacteristic(this.temperatureService, this.hap.Characteristic.SunsetTime);
61
62
  }
62
63
 
63
- // Setup linkage to EveHome app if configured todo so
64
- if (
65
- this.deviceData?.eveHistory === true &&
66
- this.airPressureService !== undefined &&
67
- typeof this.historyService?.linkToEveHome === 'function'
68
- ) {
69
- this.historyService.linkToEveHome(this.airPressureService, {
70
- description: this.deviceData.description,
71
- });
72
- }
73
-
74
64
  // Extra setup details for output
75
65
  this.deviceData?.elevation !== undefined && this.postSetupDetail('Elevation of ' + this.deviceData.elevation + 'm');
76
66
  }
77
67
 
78
- updateDevice(deviceData) {
68
+ onRemove() {
69
+ this.accessory.removeService(this.temperatureService);
70
+ this.accessory.removeService(this.humidityService);
71
+ this.accessory.removeService(this.batteryService);
72
+ this.accessory.removeService(this.airPressureService);
73
+ this.temperatureService = undefined;
74
+ this.humidityService = undefined;
75
+ this.batteryService = undefined;
76
+ this.airPressureService = undefined;
77
+ }
78
+
79
+ onUpdate(deviceData) {
79
80
  if (
80
81
  typeof deviceData !== 'object' ||
81
82
  this.temperatureService === undefined ||
@@ -97,7 +98,7 @@ export default class NestWeather extends HomeKitDevice {
97
98
  this.humidityService.updateCharacteristic(this.hap.Characteristic.CurrentRelativeHumidity, deviceData.current_humidity);
98
99
 
99
100
  if (this.airPressureService !== undefined) {
100
- //this.airPressureService.updateCharacteristic(this.hap.Characteristic.EveAirPressure, 0); // Where from??
101
+ // this.airPressureService.updateCharacteristic(this.hap.Characteristic.EveAirPressure, 0); // Where from??
101
102
  this.airPressureService.updateCharacteristic(this.hap.Characteristic.EveElevation, deviceData.elevation);
102
103
  }
103
104
 
@@ -155,17 +156,111 @@ export default class NestWeather extends HomeKitDevice {
155
156
  }
156
157
 
157
158
  // If we have the history service running, record temperature and humity every 5mins
158
- if (this.airPressureService !== undefined && typeof this.historyService?.addHistory === 'function') {
159
- this.historyService.addHistory(
160
- this.airPressureService,
161
- {
162
- time: Math.floor(Date.now() / 1000),
163
- temperature: deviceData.current_temperature,
164
- humidity: deviceData.current_humidity,
165
- pressure: 0,
166
- },
167
- 300,
168
- );
169
- }
159
+ this.history(
160
+ this.airPressureService,
161
+ { temperature: deviceData.current_temperature, humidity: deviceData.current_humidity, pressure: 0 },
162
+ { timegap: 300, force: true },
163
+ );
170
164
  }
171
165
  }
166
+
167
+ // Function to process our RAW Nest or Google for this device type
168
+ export function processRawData(log, rawData, config, deviceType = undefined) {
169
+ if (
170
+ rawData === null ||
171
+ typeof rawData !== 'object' ||
172
+ rawData?.constructor !== Object ||
173
+ typeof config !== 'object' ||
174
+ config?.constructor !== Object
175
+ ) {
176
+ return;
177
+ }
178
+
179
+ // Process data for any structure(s) for both Nest and Protobuf API data
180
+ // We use this to created virtual weather station(s) for each structure that has location data
181
+ let devices = {};
182
+ Object.entries(rawData)
183
+ .filter(
184
+ ([key]) => (key.startsWith('structure.') === true || key.startsWith('STRUCTURE_') === true) && config?.options?.weather === true, // Only if weather enabled
185
+ )
186
+ .forEach(([object_key, value]) => {
187
+ let tempDevice = {};
188
+ try {
189
+ // For a Google API source data, we use the Nest API structure ID. This will ensure we generate the same serial number
190
+ // This should prevent two 'wether' objects from being created
191
+ // Nest API uses the structure id only
192
+ let serialNumber =
193
+ value?.source === DATA_SOURCE.GOOGLE &&
194
+ typeof value.value?.structure_info?.rtsStructureId === 'string' &&
195
+ value.value.structure_info.rtsStructureId !== ''
196
+ ? value.value.structure_info.rtsStructureId.trim() // Google API STRUCTURE_xxx, translated Nest API structure.xxx
197
+ : value?.source === DATA_SOURCE.NEST
198
+ ? object_key // Nest API structure.xxx
199
+ : undefined;
200
+
201
+ let description =
202
+ value?.source === DATA_SOURCE.GOOGLE
203
+ ? (value.value?.structure_location?.city?.value?.trim() ?? '') !== '' &&
204
+ (value.value?.structure_location?.state?.value?.trim() ?? '') !== ''
205
+ ? value.value.structure_location.city.value.trim() + ' - ' + value.value.structure_location.state.value.trim()
206
+ : (value.value?.structure_info?.name?.trim() ?? '') !== ''
207
+ ? value.value.structure_info.name.trim()
208
+ : undefined
209
+ : value?.source === DATA_SOURCE.NEST
210
+ ? (value.value?.city?.trim() ?? '') !== '' && (value.value?.state?.trim() ?? '') !== ''
211
+ ? value.value.city.trim() + ' - ' + value.value.state.trim()
212
+ : (value.value?.name?.trim() ?? '') !== ''
213
+ ? value.value.name.trim()
214
+ : undefined
215
+ : undefined;
216
+
217
+ if (serialNumber !== undefined && description !== undefined) {
218
+ tempDevice = processCommonData(
219
+ object_key,
220
+ {
221
+ type: DEVICE_TYPE.WEATHER,
222
+ model: 'Weather',
223
+ softwareVersion: NestWeather.VERSION, // We'll use our class version here now
224
+ serialNumber: NESTLABS_MAC_PREFIX + crc24(serialNumber.toUpperCase()).toUpperCase(),
225
+ description: description,
226
+ current_temperature:
227
+ isNaN(value.value?.weather?.current_temperature) === false
228
+ ? adjustTemperature(Number(value.value.weather.current_temperature), 'C', 'C', true)
229
+ : 0.0,
230
+ current_humidity: isNaN(value.value?.weather?.current_humidity) === false ? Number(value.value.weather.current_humidity) : 0,
231
+ condition: value.value?.weather?.condition?.trim() ?? '',
232
+ wind_direction: value.value?.weather?.wind_direction?.trim() ?? '',
233
+ wind_speed: isNaN(value.value?.weather?.wind_speed) === false ? Number(value.value.weather.wind_speed) : 0,
234
+ sunrise: isNaN(value.value?.weather?.sunrise) === false ? Number(value.value.weather.sunrise) : '---',
235
+ sunset: isNaN(value.value?.weather?.sunset) === false ? Number(value.value.weather.sunset) : '---',
236
+ station: value.value?.weather?.station?.trim() ?? '',
237
+ forecast: value.value?.weather?.forecast?.trim() ?? '',
238
+ },
239
+ config,
240
+ );
241
+ }
242
+ // eslint-disable-next-line no-unused-vars
243
+ } catch (error) {
244
+ log?.debug?.('Error processing weather data for "%s"', object_key);
245
+ }
246
+
247
+ if (
248
+ Object.entries(tempDevice).length !== 0 &&
249
+ typeof devices[tempDevice.serialNumber] === 'undefined' &&
250
+ (deviceType === undefined || (typeof deviceType === 'string' && deviceType !== '' && tempDevice.type === deviceType))
251
+ ) {
252
+ let deviceOptions = config?.devices?.find(
253
+ (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(),
254
+ );
255
+ // Insert any extra options we've read in from configuration file for this device
256
+ tempDevice.eveHistory = config.options.eveHistory === true || deviceOptions?.eveHistory === true;
257
+ tempDevice.elevation =
258
+ isNaN(deviceOptions?.elevation) === false && Number(deviceOptions?.elevation) >= 0 && Number(deviceOptions?.elevation) <= 8848
259
+ ? Number(deviceOptions?.elevation)
260
+ : config.options.elevation;
261
+ devices[tempDevice.serialNumber] = tempDevice; // Store processed device
262
+ }
263
+ });
264
+
265
+ return devices;
266
+ }
@@ -1,7 +1,7 @@
1
1
  syntax = "proto3";
2
2
 
3
3
  import "google/protobuf/timestamp.proto";
4
- import "weave/common.proto";
4
+ import "../../weave/common.proto";
5
5
 
6
6
  package nest.services.apigateway;
7
7
 
@@ -100,5 +100,5 @@ message ObserveResponse {
100
100
  }
101
101
 
102
102
  service GatewayService {
103
- rpc Observe(ObserveRequest) returns (ObserveResponse);
103
+ rpc Observe (ObserveRequest) returns (stream ObserveResponse);
104
104
  }
@@ -37,5 +37,6 @@ import "weave/trait/power.proto";
37
37
  import "weave/trait/schedule.proto";
38
38
  import "weave/trait/security.proto";
39
39
  import "weave/trait/time.proto";
40
+ import "nest/services/apigateway.proto";
40
41
  import "nestlabs/gateway/v2.proto";
41
42
  import "nestlabs/history/v1.proto";
@@ -0,0 +1,186 @@
1
+ // rtpmuxer.js
2
+ // Unified RTP Muxer + Stream Engine with FFmpeg support
3
+ // Part of homebridge-nest-accfactory
4
+ //
5
+ // Code version 2025.07.04
6
+ // Mark Hulskamp
7
+ 'use strict';
8
+
9
+ // Define nodejs module requirements
10
+ import dgram from 'dgram';
11
+ import { Writable } from 'stream';
12
+ import { Buffer } from 'node:buffer';
13
+ import { setInterval, clearInterval } from 'node:timers';
14
+
15
+ // Define constants
16
+ const LOG_LEVELS = {
17
+ INFO: 'info',
18
+ SUCCESS: 'success',
19
+ WARN: 'warn',
20
+ ERROR: 'error',
21
+ DEBUG: 'debug',
22
+ };
23
+ const RTP_PACKET_HEADER_SIZE = 12;
24
+
25
+ export default class RTPMuxer {
26
+ static RTP_PORT_START = 50000;
27
+ static RTP_PORT_END = 51000;
28
+ static SAMPLE_RATE_VIDEO = 90000;
29
+ static SAMPLE_RATE_AUDIO = 48000;
30
+ static PAYLOAD_TYPE_H264 = 96;
31
+ static PAYLOAD_TYPE_OPUS = 111;
32
+
33
+ static STREAM_TYPE = {
34
+ BUFFER: 'buffer',
35
+ LIVE: 'live',
36
+ RECORD: 'record',
37
+ TALK: 'talk',
38
+ };
39
+
40
+ log = undefined; // Logging function object
41
+
42
+ #udpServer = undefined; // UDP server for RTP packets
43
+ #port = undefined; // UDP port for RTP packets
44
+ #outputSessions = new Map(); // Output sessions for RTP streams
45
+ #buffer = []; // Buffer for RTP packets
46
+ #bufferDuration = 5000;
47
+ #bufferTimer = undefined; // Timer for buffer cleanup
48
+ #ffmpeg = undefined; // FFmpeg instance for processing RTP streams
49
+
50
+ constructor(options) {
51
+ // Setup logger object if passed as option
52
+ if (Object.values(LOG_LEVELS).every((fn) => typeof options?.log?.[fn] === 'function')) {
53
+ this.log = options.log;
54
+ }
55
+
56
+ this.#ffmpeg = options.ffmpeg; // pass instance of FFmpeg from ffmpeg.js
57
+ }
58
+
59
+ async start() {
60
+ this.#port = await this.#allocatePort();
61
+ this.#udpServer = dgram.createSocket('udp4');
62
+ this.#udpServer.on('message', (msg) => this.#handleRTP(msg));
63
+ this.#udpServer.bind(this.#port);
64
+ this.#startBufferLoop();
65
+ }
66
+
67
+ stop(uuid) {
68
+ if (this.#udpServer) {
69
+ this.#udpServer.close();
70
+ this.#udpServer = undefined;
71
+ }
72
+
73
+ clearInterval(this.#bufferTimer);
74
+ this.#outputSessions.clear();
75
+
76
+ this.#ffmpeg?.killAllSessions?.(uuid);
77
+ }
78
+
79
+ getPort() {
80
+ return this.#port;
81
+ }
82
+
83
+ getSDP(kind) {
84
+ let sdp = '';
85
+ if (kind === 'video') {
86
+ sdp += 'm=video ' + this.#port + ' RTP/AVP ' + RTPMuxer.PAYLOAD_TYPE_H264 + '\r\n';
87
+ sdp += 'a=rtpmap:' + RTPMuxer.PAYLOAD_TYPE_H264 + ' H264/' + RTPMuxer.SAMPLE_RATE_VIDEO + '\r\n';
88
+ } else if (kind === 'audio') {
89
+ sdp += 'm=audio ' + this.#port + ' RTP/AVP ' + RTPMuxer.PAYLOAD_TYPE_OPUS + '\r\n';
90
+ sdp += 'a=rtpmap:' + RTPMuxer.PAYLOAD_TYPE_OPUS + ' opus/' + RTPMuxer.SAMPLE_RATE_AUDIO + '/2\r\n';
91
+ }
92
+ return sdp;
93
+ }
94
+
95
+ attachOutput(sessionID, writableStream, options = {}) {
96
+ this.#outputSessions.set(sessionID, {
97
+ stream: writableStream,
98
+ kind: options.kind,
99
+ isRecording: options.isRecording === true,
100
+ });
101
+ }
102
+
103
+ detachOutput(sessionID) {
104
+ this.#outputSessions.delete(sessionID);
105
+ }
106
+
107
+ getWritableStream(type) {
108
+ return new Writable({
109
+ write: (chunk, encoding, callback) => {
110
+ if (type === RTPMuxer.STREAM_TYPE.BUFFER) {
111
+ this.#buffer.push({ time: Date.now(), data: chunk });
112
+ }
113
+ for (let session of this.#outputSessions.values()) {
114
+ if (session.kind === type || session.kind === undefined) {
115
+ session.stream.write(chunk);
116
+ }
117
+ }
118
+ callback();
119
+ },
120
+ });
121
+ }
122
+
123
+ getBufferedPackets(kind) {
124
+ let now = Date.now();
125
+ return this.#buffer.filter((p) => p.kind === kind && now - p.timestamp <= this.#bufferDuration).map((p) => p.packet);
126
+ }
127
+
128
+ startSession(uuid, sessionID, args, sessionType = 'live', errorCallback, pipeCount = 4) {
129
+ return this.#ffmpeg?.createSession?.(uuid, sessionID, args, sessionType, errorCallback, pipeCount);
130
+ }
131
+
132
+ stopSession(uuid, sessionID, sessionType = 'live') {
133
+ return this.#ffmpeg?.killSession?.(uuid, sessionID, sessionType);
134
+ }
135
+
136
+ processRTP(packet) {
137
+ this.#handleRTP(packet);
138
+ }
139
+
140
+ #handleRTP(packet) {
141
+ if (Buffer.isBuffer(packet) === false || packet.length < RTP_PACKET_HEADER_SIZE) {
142
+ return;
143
+ }
144
+
145
+ let payloadType = packet[1] & 0x7f;
146
+ let kind = payloadType === RTPMuxer.PAYLOAD_TYPE_H264 ? 'video' : payloadType === RTPMuxer.PAYLOAD_TYPE_OPUS ? 'audio' : undefined;
147
+ if (kind === undefined) {
148
+ return;
149
+ }
150
+
151
+ let copy = Buffer.from(packet);
152
+ this.#buffer.push({ kind, timestamp: Date.now(), packet: copy });
153
+
154
+ for (let session of this.#outputSessions.values()) {
155
+ if (session.kind === kind || session.kind === undefined) {
156
+ session.stream.write(copy);
157
+ }
158
+ }
159
+ }
160
+
161
+ #startBufferLoop() {
162
+ this.#bufferTimer = setInterval(() => {
163
+ let now = Date.now();
164
+ this.#buffer = this.#buffer.filter((p) => now - p.timestamp <= this.#bufferDuration);
165
+ }, 1000);
166
+ }
167
+
168
+ async #allocatePort() {
169
+ for (let port = RTPMuxer.RTP_PORT_START; port <= RTPMuxer.RTP_PORT_END; port += 2) {
170
+ try {
171
+ await new Promise((resolve, reject) => {
172
+ let socket = dgram.createSocket('udp4');
173
+ socket.once('error', reject);
174
+ socket.bind(port, () => {
175
+ socket.close();
176
+ resolve();
177
+ });
178
+ });
179
+ return port;
180
+ } catch {
181
+ // try next port
182
+ }
183
+ }
184
+ throw new Error('No available UDP port');
185
+ }
186
+ }