homebridge-netatmo-temperature 1.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/config.schema.json +40 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/netatmoApi.d.ts +24 -0
- package/dist/netatmoApi.js +126 -0
- package/dist/platform.d.ts +16 -0
- package/dist/platform.js +136 -0
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +5 -0
- package/package.json +35 -0
- package/src/index.ts +3 -0
- package/src/netatmoApi.ts +156 -0
- package/src/platform.ts +166 -0
- package/src/settings.ts +2 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "NetatmoTemperature",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Netatmo Weather Station temperatures for HomeKit. Get your credentials at https://dev.netatmo.com",
|
|
6
|
+
"schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"clientId": {
|
|
10
|
+
"title": "Client ID",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"required": true,
|
|
13
|
+
"description": "Client ID from your Netatmo app at dev.netatmo.com"
|
|
14
|
+
},
|
|
15
|
+
"clientSecret": {
|
|
16
|
+
"title": "Client Secret",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"required": true,
|
|
19
|
+
"description": "Client Secret from your Netatmo app at dev.netatmo.com"
|
|
20
|
+
},
|
|
21
|
+
"refreshToken": {
|
|
22
|
+
"title": "Refresh Token",
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "OAuth2 Refresh Token from Netatmo (auto-renews access tokens)"
|
|
25
|
+
},
|
|
26
|
+
"token": {
|
|
27
|
+
"title": "Access Token (optional)",
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Direct access token - use if you don't have a refresh token yet"
|
|
30
|
+
},
|
|
31
|
+
"pollingInterval": {
|
|
32
|
+
"title": "Polling Interval (seconds)",
|
|
33
|
+
"type": "integer",
|
|
34
|
+
"default": 300,
|
|
35
|
+
"minimum": 60,
|
|
36
|
+
"description": "How often to refresh temperature data (default: 300s = 5 min)"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface NetatmoModule {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
type: string;
|
|
5
|
+
temperature: number | null;
|
|
6
|
+
humidity: number | null;
|
|
7
|
+
co2?: number | null;
|
|
8
|
+
pressure?: number | null;
|
|
9
|
+
noise?: number | null;
|
|
10
|
+
battery?: number | null;
|
|
11
|
+
reachable: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare class NetatmoApi {
|
|
14
|
+
private client;
|
|
15
|
+
private accessToken;
|
|
16
|
+
private refreshToken;
|
|
17
|
+
private clientId;
|
|
18
|
+
private clientSecret;
|
|
19
|
+
private tokenExpiry;
|
|
20
|
+
private log;
|
|
21
|
+
constructor(clientId: string, clientSecret: string, refreshToken: string, log: Logger, directToken?: string);
|
|
22
|
+
authenticate(): Promise<void>;
|
|
23
|
+
getStationData(): Promise<NetatmoModule[]>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.NetatmoApi = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
class NetatmoApi {
|
|
9
|
+
constructor(clientId, clientSecret, refreshToken, log, directToken) {
|
|
10
|
+
this.tokenExpiry = 0;
|
|
11
|
+
this.clientId = clientId;
|
|
12
|
+
this.clientSecret = clientSecret;
|
|
13
|
+
this.refreshToken = refreshToken;
|
|
14
|
+
this.log = log;
|
|
15
|
+
this.accessToken = directToken || '';
|
|
16
|
+
this.client = axios_1.default.create({
|
|
17
|
+
baseURL: 'https://api.netatmo.com',
|
|
18
|
+
timeout: 30000,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async authenticate() {
|
|
22
|
+
// If we have a direct token and it hasn't expired, use it
|
|
23
|
+
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// If we have a direct token but no expiry set, assume valid for 3 hours
|
|
27
|
+
if (this.accessToken && this.tokenExpiry === 0) {
|
|
28
|
+
this.tokenExpiry = Date.now() + 10800000;
|
|
29
|
+
this.client.defaults.headers.common['Authorization'] = 'Bearer ' + this.accessToken;
|
|
30
|
+
this.log.info('Netatmo: using configured access token');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Use refresh_token to get a new access_token
|
|
34
|
+
if (!this.refreshToken) {
|
|
35
|
+
throw new Error('No refresh token configured. Please provide a refresh_token or access token.');
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
this.log.info('Netatmo: refreshing access token...');
|
|
39
|
+
const res = await this.client.post('/oauth2/token', new URLSearchParams({
|
|
40
|
+
grant_type: 'refresh_token',
|
|
41
|
+
refresh_token: this.refreshToken,
|
|
42
|
+
client_id: this.clientId,
|
|
43
|
+
client_secret: this.clientSecret,
|
|
44
|
+
}).toString(), {
|
|
45
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
46
|
+
});
|
|
47
|
+
this.accessToken = res.data.access_token;
|
|
48
|
+
this.refreshToken = res.data.refresh_token || this.refreshToken;
|
|
49
|
+
this.tokenExpiry = Date.now() + (res.data.expires_in || 10800) * 1000;
|
|
50
|
+
this.client.defaults.headers.common['Authorization'] = 'Bearer ' + this.accessToken;
|
|
51
|
+
this.log.info('Netatmo: authenticated successfully (token valid for', Math.round((res.data.expires_in || 10800) / 3600), 'hours)');
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
55
|
+
this.log.error('Netatmo: authentication failed -', msg);
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async getStationData() {
|
|
60
|
+
await this.authenticate();
|
|
61
|
+
try {
|
|
62
|
+
const res = await this.client.get('/api/getstationsdata', {
|
|
63
|
+
headers: { 'Authorization': 'Bearer ' + this.accessToken },
|
|
64
|
+
});
|
|
65
|
+
const devices = res.data?.body?.devices;
|
|
66
|
+
if (!devices || devices.length === 0) {
|
|
67
|
+
this.log.warn('Netatmo: no weather stations found');
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const modules = [];
|
|
71
|
+
for (const device of devices) {
|
|
72
|
+
// Main indoor station
|
|
73
|
+
const mainData = device.dashboard_data || {};
|
|
74
|
+
modules.push({
|
|
75
|
+
id: device._id,
|
|
76
|
+
name: device.station_name || device.module_name || 'Indoor',
|
|
77
|
+
type: device.type || 'NAMain',
|
|
78
|
+
temperature: mainData.Temperature ?? null,
|
|
79
|
+
humidity: mainData.Humidity ?? null,
|
|
80
|
+
co2: mainData.CO2 ?? null,
|
|
81
|
+
pressure: mainData.Pressure ?? null,
|
|
82
|
+
noise: mainData.Noise ?? null,
|
|
83
|
+
battery: null, // main station is plugged in
|
|
84
|
+
reachable: true,
|
|
85
|
+
});
|
|
86
|
+
// Sub-modules (outdoor, extra indoor, rain, wind)
|
|
87
|
+
if (device.modules) {
|
|
88
|
+
for (const mod of device.modules) {
|
|
89
|
+
const modData = mod.dashboard_data || {};
|
|
90
|
+
// Only include modules that have temperature
|
|
91
|
+
if (modData.Temperature !== undefined) {
|
|
92
|
+
modules.push({
|
|
93
|
+
id: mod._id,
|
|
94
|
+
name: mod.module_name || mod.type || 'Module',
|
|
95
|
+
type: mod.type || 'unknown',
|
|
96
|
+
temperature: modData.Temperature ?? null,
|
|
97
|
+
humidity: modData.Humidity ?? null,
|
|
98
|
+
co2: modData.CO2 ?? null,
|
|
99
|
+
pressure: null,
|
|
100
|
+
noise: null,
|
|
101
|
+
battery: mod.battery_percent ?? null,
|
|
102
|
+
reachable: mod.reachable ?? false,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
this.log.info('Netatmo: found', modules.length, 'temperature modules');
|
|
109
|
+
return modules;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
// If 403/401, try to re-authenticate
|
|
113
|
+
if (axios_1.default.isAxiosError(e) && (e.response?.status === 401 || e.response?.status === 403)) {
|
|
114
|
+
this.log.warn('Netatmo: token expired, re-authenticating...');
|
|
115
|
+
this.tokenExpiry = 0;
|
|
116
|
+
this.accessToken = '';
|
|
117
|
+
await this.authenticate();
|
|
118
|
+
return this.getStationData();
|
|
119
|
+
}
|
|
120
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
121
|
+
this.log.error('Netatmo: failed to get station data -', msg);
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.NetatmoApi = NetatmoApi;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare class NetatmoPlatform implements DynamicPlatformPlugin {
|
|
2
|
+
readonly log: Logger;
|
|
3
|
+
readonly config: PlatformConfig;
|
|
4
|
+
readonly api: API;
|
|
5
|
+
readonly Service: typeof Service;
|
|
6
|
+
readonly Characteristic: typeof Characteristic;
|
|
7
|
+
private netatmoApi;
|
|
8
|
+
private cachedAccessories;
|
|
9
|
+
private pollingInterval;
|
|
10
|
+
private modules;
|
|
11
|
+
constructor(log: Logger, config: PlatformConfig, api: API);
|
|
12
|
+
configureAccessory(accessory: PlatformAccessory): void;
|
|
13
|
+
onReady(): Promise<void>;
|
|
14
|
+
registerAccessories(modules: NetatmoModule[]): void;
|
|
15
|
+
poll(): Promise<void>;
|
|
16
|
+
}
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NetatmoPlatform = void 0;
|
|
4
|
+
class NetatmoPlatform {
|
|
5
|
+
constructor(log, config, api) {
|
|
6
|
+
this.log = log;
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.api = api;
|
|
9
|
+
this.Service = this.api.hap.Service;
|
|
10
|
+
this.Characteristic = this.api.hap.Characteristic;
|
|
11
|
+
this.cachedAccessories = [];
|
|
12
|
+
this.modules = new Map();
|
|
13
|
+
this.pollingInterval = (config.pollingInterval || 300) * 1000; // default 5 min
|
|
14
|
+
this.api.on('didFinishLaunching', () => this.onReady());
|
|
15
|
+
}
|
|
16
|
+
configureAccessory(accessory) {
|
|
17
|
+
this.log.info('Restoring cached accessory:', accessory.displayName);
|
|
18
|
+
this.cachedAccessories.push(accessory);
|
|
19
|
+
}
|
|
20
|
+
async onReady() {
|
|
21
|
+
// Validate config
|
|
22
|
+
if (!this.config.refreshToken && !this.config.token) {
|
|
23
|
+
this.log.error('Missing refresh_token or token in config. Please configure Netatmo credentials.');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.netatmoApi = new NetatmoApi(this.config.clientId || '', this.config.clientSecret || '', this.config.refreshToken || '', this.log, this.config.token || '');
|
|
27
|
+
try {
|
|
28
|
+
await this.netatmoApi.authenticate();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
this.log.error('Failed to connect to Netatmo API');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Get initial data and register accessories
|
|
35
|
+
try {
|
|
36
|
+
const modules = await this.netatmoApi.getStationData();
|
|
37
|
+
this.registerAccessories(modules);
|
|
38
|
+
this.log.info('Netatmo: total sensors registered:', modules.length);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
this.log.error('Failed to get initial Netatmo data');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// Start polling
|
|
45
|
+
setInterval(() => this.poll(), this.pollingInterval);
|
|
46
|
+
}
|
|
47
|
+
registerAccessories(modules) {
|
|
48
|
+
const newAccessories = [];
|
|
49
|
+
for (const mod of modules) {
|
|
50
|
+
const uuid = this.api.hap.uuid.generate(PLUGIN_NAME + '-' + mod.id);
|
|
51
|
+
let accessory = this.cachedAccessories.find(a => a.UUID === uuid);
|
|
52
|
+
if (!accessory) {
|
|
53
|
+
const displayName = mod.name;
|
|
54
|
+
accessory = new this.api.platformAccessory(displayName, uuid);
|
|
55
|
+
newAccessories.push(accessory);
|
|
56
|
+
this.log.info('Adding new accessory:', displayName);
|
|
57
|
+
}
|
|
58
|
+
// Setup TemperatureSensor service
|
|
59
|
+
let tempService = accessory.getService(this.Service.TemperatureSensor);
|
|
60
|
+
if (!tempService) {
|
|
61
|
+
tempService = accessory.addService(this.Service.TemperatureSensor, mod.name);
|
|
62
|
+
}
|
|
63
|
+
// Set initial temperature
|
|
64
|
+
if (mod.temperature !== null) {
|
|
65
|
+
tempService.getCharacteristic(this.Characteristic.CurrentTemperature)
|
|
66
|
+
.updateValue(mod.temperature);
|
|
67
|
+
}
|
|
68
|
+
// Setup HumiditySensor service if humidity data available
|
|
69
|
+
if (mod.humidity !== null) {
|
|
70
|
+
let humService = accessory.getService(this.Service.HumiditySensor);
|
|
71
|
+
if (!humService) {
|
|
72
|
+
humService = accessory.addService(this.Service.HumiditySensor, mod.name + ' Humidity');
|
|
73
|
+
}
|
|
74
|
+
humService.getCharacteristic(this.Characteristic.CurrentRelativeHumidity)
|
|
75
|
+
.updateValue(mod.humidity);
|
|
76
|
+
}
|
|
77
|
+
// Set accessory info
|
|
78
|
+
const infoService = accessory.getService(this.Service.AccessoryInformation);
|
|
79
|
+
if (infoService) {
|
|
80
|
+
infoService
|
|
81
|
+
.setCharacteristic(this.Characteristic.Manufacturer, 'Netatmo')
|
|
82
|
+
.setCharacteristic(this.Characteristic.Model, mod.type)
|
|
83
|
+
.setCharacteristic(this.Characteristic.SerialNumber, mod.id);
|
|
84
|
+
}
|
|
85
|
+
this.modules.set(mod.id, { accessory, data: mod });
|
|
86
|
+
}
|
|
87
|
+
if (newAccessories.length > 0) {
|
|
88
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, newAccessories);
|
|
89
|
+
}
|
|
90
|
+
// Remove orphaned accessories
|
|
91
|
+
const activeUUIDs = new Set(modules.map(m => this.api.hap.uuid.generate(PLUGIN_NAME + '-' + m.id)));
|
|
92
|
+
const orphans = this.cachedAccessories.filter(a => !activeUUIDs.has(a.UUID));
|
|
93
|
+
if (orphans.length > 0) {
|
|
94
|
+
this.log.info('Removing', orphans.length, 'orphaned accessories');
|
|
95
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, orphans);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async poll() {
|
|
99
|
+
try {
|
|
100
|
+
const modules = await this.netatmoApi.getStationData();
|
|
101
|
+
for (const mod of modules) {
|
|
102
|
+
const entry = this.modules.get(mod.id);
|
|
103
|
+
if (!entry) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const { accessory } = entry;
|
|
107
|
+
// Update temperature
|
|
108
|
+
if (mod.temperature !== null) {
|
|
109
|
+
const tempService = accessory.getService(this.Service.TemperatureSensor);
|
|
110
|
+
if (tempService) {
|
|
111
|
+
tempService.getCharacteristic(this.Characteristic.CurrentTemperature)
|
|
112
|
+
.updateValue(mod.temperature);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Update humidity
|
|
116
|
+
if (mod.humidity !== null) {
|
|
117
|
+
const humService = accessory.getService(this.Service.HumiditySensor);
|
|
118
|
+
if (humService) {
|
|
119
|
+
humService.getCharacteristic(this.Characteristic.CurrentRelativeHumidity)
|
|
120
|
+
.updateValue(mod.humidity);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
entry.data = mod;
|
|
124
|
+
}
|
|
125
|
+
// Log summary
|
|
126
|
+
const summary = modules.map(m => m.name + '=' + (m.temperature !== null ? m.temperature + '°C' : 'N/A') +
|
|
127
|
+
(m.humidity !== null ? ' ' + m.humidity + '%' : '')).join(', ');
|
|
128
|
+
this.log.info('Netatmo poll:', summary);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
132
|
+
this.log.error('Netatmo poll failed:', msg);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
exports.NetatmoPlatform = NetatmoPlatform;
|
package/dist/settings.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-netatmo-temperature",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Homebridge plugin for Netatmo weather station temperatures in Apple HomeKit",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "trama2000",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/trama2000/homebridge-netatmo-temperature.git"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"prepublishOnly": "echo skip"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"homebridge-plugin",
|
|
18
|
+
"netatmo",
|
|
19
|
+
"temperature",
|
|
20
|
+
"weather",
|
|
21
|
+
"homekit"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"homebridge": ">=1.6.0",
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"axios": "^1.6.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^20.0.0",
|
|
32
|
+
"homebridge": "^1.8.0",
|
|
33
|
+
"typescript": "^5.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import axios, { AxiosInstance } from 'axios';
|
|
2
|
+
export interface NetatmoModule {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
type: string; // NAMain, NAModule1 (outdoor), NAModule4 (indoor extra)
|
|
6
|
+
temperature: number | null;
|
|
7
|
+
humidity: number | null;
|
|
8
|
+
co2?: number | null;
|
|
9
|
+
pressure?: number | null;
|
|
10
|
+
noise?: number | null;
|
|
11
|
+
battery?: number | null;
|
|
12
|
+
reachable: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class NetatmoApi {
|
|
16
|
+
private client: AxiosInstance;
|
|
17
|
+
private accessToken: string;
|
|
18
|
+
private refreshToken: string;
|
|
19
|
+
private clientId: string;
|
|
20
|
+
private clientSecret: string;
|
|
21
|
+
private tokenExpiry = 0;
|
|
22
|
+
private log: Logger;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
clientId: string,
|
|
26
|
+
clientSecret: string,
|
|
27
|
+
refreshToken: string,
|
|
28
|
+
log: Logger,
|
|
29
|
+
directToken?: string,
|
|
30
|
+
) {
|
|
31
|
+
this.clientId = clientId;
|
|
32
|
+
this.clientSecret = clientSecret;
|
|
33
|
+
this.refreshToken = refreshToken;
|
|
34
|
+
this.log = log;
|
|
35
|
+
this.accessToken = directToken || '';
|
|
36
|
+
|
|
37
|
+
this.client = axios.create({
|
|
38
|
+
baseURL: 'https://api.netatmo.com',
|
|
39
|
+
timeout: 30000,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async authenticate(): Promise<void> {
|
|
44
|
+
// If we have a direct token and it hasn't expired, use it
|
|
45
|
+
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// If we have a direct token but no expiry set, assume valid for 3 hours
|
|
50
|
+
if (this.accessToken && this.tokenExpiry === 0) {
|
|
51
|
+
this.tokenExpiry = Date.now() + 10800000;
|
|
52
|
+
this.client.defaults.headers.common['Authorization'] = 'Bearer ' + this.accessToken;
|
|
53
|
+
this.log.info('Netatmo: using configured access token');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Use refresh_token to get a new access_token
|
|
58
|
+
if (!this.refreshToken) {
|
|
59
|
+
throw new Error('No refresh token configured. Please provide a refresh_token or access token.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
this.log.info('Netatmo: refreshing access token...');
|
|
64
|
+
const res = await this.client.post('/oauth2/token', new URLSearchParams({
|
|
65
|
+
grant_type: 'refresh_token',
|
|
66
|
+
refresh_token: this.refreshToken,
|
|
67
|
+
client_id: this.clientId,
|
|
68
|
+
client_secret: this.clientSecret,
|
|
69
|
+
}).toString(), {
|
|
70
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.accessToken = res.data.access_token;
|
|
74
|
+
this.refreshToken = res.data.refresh_token || this.refreshToken;
|
|
75
|
+
this.tokenExpiry = Date.now() + (res.data.expires_in || 10800) * 1000;
|
|
76
|
+
this.client.defaults.headers.common['Authorization'] = 'Bearer ' + this.accessToken;
|
|
77
|
+
this.log.info('Netatmo: authenticated successfully (token valid for', Math.round((res.data.expires_in || 10800) / 3600), 'hours)');
|
|
78
|
+
} catch (e) {
|
|
79
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
80
|
+
this.log.error('Netatmo: authentication failed -', msg);
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getStationData(): Promise<NetatmoModule[]> {
|
|
86
|
+
await this.authenticate();
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const res = await this.client.get('/api/getstationsdata', {
|
|
90
|
+
headers: { 'Authorization': 'Bearer ' + this.accessToken },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const devices = res.data?.body?.devices;
|
|
94
|
+
if (!devices || devices.length === 0) {
|
|
95
|
+
this.log.warn('Netatmo: no weather stations found');
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const modules: NetatmoModule[] = [];
|
|
100
|
+
|
|
101
|
+
for (const device of devices) {
|
|
102
|
+
// Main indoor station
|
|
103
|
+
const mainData = device.dashboard_data || {};
|
|
104
|
+
modules.push({
|
|
105
|
+
id: device._id,
|
|
106
|
+
name: device.station_name || device.module_name || 'Indoor',
|
|
107
|
+
type: device.type || 'NAMain',
|
|
108
|
+
temperature: mainData.Temperature ?? null,
|
|
109
|
+
humidity: mainData.Humidity ?? null,
|
|
110
|
+
co2: mainData.CO2 ?? null,
|
|
111
|
+
pressure: mainData.Pressure ?? null,
|
|
112
|
+
noise: mainData.Noise ?? null,
|
|
113
|
+
battery: null, // main station is plugged in
|
|
114
|
+
reachable: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Sub-modules (outdoor, extra indoor, rain, wind)
|
|
118
|
+
if (device.modules) {
|
|
119
|
+
for (const mod of device.modules) {
|
|
120
|
+
const modData = mod.dashboard_data || {};
|
|
121
|
+
// Only include modules that have temperature
|
|
122
|
+
if (modData.Temperature !== undefined) {
|
|
123
|
+
modules.push({
|
|
124
|
+
id: mod._id,
|
|
125
|
+
name: mod.module_name || mod.type || 'Module',
|
|
126
|
+
type: mod.type || 'unknown',
|
|
127
|
+
temperature: modData.Temperature ?? null,
|
|
128
|
+
humidity: modData.Humidity ?? null,
|
|
129
|
+
co2: modData.CO2 ?? null,
|
|
130
|
+
pressure: null,
|
|
131
|
+
noise: null,
|
|
132
|
+
battery: mod.battery_percent ?? null,
|
|
133
|
+
reachable: mod.reachable ?? false,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.log.info('Netatmo: found', modules.length, 'temperature modules');
|
|
141
|
+
return modules;
|
|
142
|
+
} catch (e: unknown) {
|
|
143
|
+
// If 403/401, try to re-authenticate
|
|
144
|
+
if (axios.isAxiosError(e) && (e.response?.status === 401 || e.response?.status === 403)) {
|
|
145
|
+
this.log.warn('Netatmo: token expired, re-authenticating...');
|
|
146
|
+
this.tokenExpiry = 0;
|
|
147
|
+
this.accessToken = '';
|
|
148
|
+
await this.authenticate();
|
|
149
|
+
return this.getStationData();
|
|
150
|
+
}
|
|
151
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
152
|
+
this.log.error('Netatmo: failed to get station data -', msg);
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
export class NetatmoPlatform implements DynamicPlatformPlugin {
|
|
2
|
+
public readonly Service: typeof Service = this.api.hap.Service;
|
|
3
|
+
public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;
|
|
4
|
+
|
|
5
|
+
private netatmoApi!: NetatmoApi;
|
|
6
|
+
private cachedAccessories: PlatformAccessory[] = [];
|
|
7
|
+
private pollingInterval: number;
|
|
8
|
+
private modules: Map<string, { accessory: PlatformAccessory; data: NetatmoModule }> = new Map();
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly log: Logger,
|
|
12
|
+
public readonly config: PlatformConfig,
|
|
13
|
+
public readonly api: API,
|
|
14
|
+
) {
|
|
15
|
+
this.pollingInterval = (config.pollingInterval || 300) * 1000; // default 5 min
|
|
16
|
+
this.api.on('didFinishLaunching', () => this.onReady());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
configureAccessory(accessory: PlatformAccessory) {
|
|
20
|
+
this.log.info('Restoring cached accessory:', accessory.displayName);
|
|
21
|
+
this.cachedAccessories.push(accessory);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async onReady() {
|
|
25
|
+
// Validate config
|
|
26
|
+
if (!this.config.refreshToken && !this.config.token) {
|
|
27
|
+
this.log.error('Missing refresh_token or token in config. Please configure Netatmo credentials.');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.netatmoApi = new NetatmoApi(
|
|
32
|
+
this.config.clientId || '',
|
|
33
|
+
this.config.clientSecret || '',
|
|
34
|
+
this.config.refreshToken || '',
|
|
35
|
+
this.log,
|
|
36
|
+
this.config.token || '',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await this.netatmoApi.authenticate();
|
|
41
|
+
} catch {
|
|
42
|
+
this.log.error('Failed to connect to Netatmo API');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get initial data and register accessories
|
|
47
|
+
try {
|
|
48
|
+
const modules = await this.netatmoApi.getStationData();
|
|
49
|
+
this.registerAccessories(modules);
|
|
50
|
+
this.log.info('Netatmo: total sensors registered:', modules.length);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
this.log.error('Failed to get initial Netatmo data');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Start polling
|
|
57
|
+
setInterval(() => this.poll(), this.pollingInterval);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
registerAccessories(modules: NetatmoModule[]) {
|
|
61
|
+
const newAccessories: PlatformAccessory[] = [];
|
|
62
|
+
|
|
63
|
+
for (const mod of modules) {
|
|
64
|
+
const uuid = this.api.hap.uuid.generate(PLUGIN_NAME + '-' + mod.id);
|
|
65
|
+
let accessory = this.cachedAccessories.find(a => a.UUID === uuid);
|
|
66
|
+
|
|
67
|
+
if (!accessory) {
|
|
68
|
+
const displayName = mod.name;
|
|
69
|
+
accessory = new this.api.platformAccessory(displayName, uuid);
|
|
70
|
+
newAccessories.push(accessory);
|
|
71
|
+
this.log.info('Adding new accessory:', displayName);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Setup TemperatureSensor service
|
|
75
|
+
let tempService = accessory.getService(this.Service.TemperatureSensor);
|
|
76
|
+
if (!tempService) {
|
|
77
|
+
tempService = accessory.addService(this.Service.TemperatureSensor, mod.name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Set initial temperature
|
|
81
|
+
if (mod.temperature !== null) {
|
|
82
|
+
tempService.getCharacteristic(this.Characteristic.CurrentTemperature)
|
|
83
|
+
.updateValue(mod.temperature);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Setup HumiditySensor service if humidity data available
|
|
87
|
+
if (mod.humidity !== null) {
|
|
88
|
+
let humService = accessory.getService(this.Service.HumiditySensor);
|
|
89
|
+
if (!humService) {
|
|
90
|
+
humService = accessory.addService(this.Service.HumiditySensor, mod.name + ' Humidity');
|
|
91
|
+
}
|
|
92
|
+
humService.getCharacteristic(this.Characteristic.CurrentRelativeHumidity)
|
|
93
|
+
.updateValue(mod.humidity);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Set accessory info
|
|
97
|
+
const infoService = accessory.getService(this.Service.AccessoryInformation);
|
|
98
|
+
if (infoService) {
|
|
99
|
+
infoService
|
|
100
|
+
.setCharacteristic(this.Characteristic.Manufacturer, 'Netatmo')
|
|
101
|
+
.setCharacteristic(this.Characteristic.Model, mod.type)
|
|
102
|
+
.setCharacteristic(this.Characteristic.SerialNumber, mod.id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.modules.set(mod.id, { accessory, data: mod });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (newAccessories.length > 0) {
|
|
109
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, newAccessories);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Remove orphaned accessories
|
|
113
|
+
const activeUUIDs = new Set(modules.map(m => this.api.hap.uuid.generate(PLUGIN_NAME + '-' + m.id)));
|
|
114
|
+
const orphans = this.cachedAccessories.filter(a => !activeUUIDs.has(a.UUID));
|
|
115
|
+
if (orphans.length > 0) {
|
|
116
|
+
this.log.info('Removing', orphans.length, 'orphaned accessories');
|
|
117
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, orphans);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async poll() {
|
|
122
|
+
try {
|
|
123
|
+
const modules = await this.netatmoApi.getStationData();
|
|
124
|
+
|
|
125
|
+
for (const mod of modules) {
|
|
126
|
+
const entry = this.modules.get(mod.id);
|
|
127
|
+
if (!entry) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { accessory } = entry;
|
|
132
|
+
|
|
133
|
+
// Update temperature
|
|
134
|
+
if (mod.temperature !== null) {
|
|
135
|
+
const tempService = accessory.getService(this.Service.TemperatureSensor);
|
|
136
|
+
if (tempService) {
|
|
137
|
+
tempService.getCharacteristic(this.Characteristic.CurrentTemperature)
|
|
138
|
+
.updateValue(mod.temperature);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Update humidity
|
|
143
|
+
if (mod.humidity !== null) {
|
|
144
|
+
const humService = accessory.getService(this.Service.HumiditySensor);
|
|
145
|
+
if (humService) {
|
|
146
|
+
humService.getCharacteristic(this.Characteristic.CurrentRelativeHumidity)
|
|
147
|
+
.updateValue(mod.humidity);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
entry.data = mod;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Log summary
|
|
155
|
+
const summary = modules.map(m =>
|
|
156
|
+
m.name + '=' + (m.temperature !== null ? m.temperature + '°C' : 'N/A') +
|
|
157
|
+
(m.humidity !== null ? ' ' + m.humidity + '%' : '')
|
|
158
|
+
).join(', ');
|
|
159
|
+
this.log.info('Netatmo poll:', summary);
|
|
160
|
+
|
|
161
|
+
} catch (e) {
|
|
162
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
163
|
+
this.log.error('Netatmo poll failed:', msg);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/settings.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": [
|
|
6
|
+
"ES2020"
|
|
7
|
+
],
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noImplicitAny": true,
|
|
11
|
+
"strictNullChecks": true,
|
|
12
|
+
"outDir": "./dist",
|
|
13
|
+
"rootDir": "./src",
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true
|
|
16
|
+
},
|
|
17
|
+
"include": [
|
|
18
|
+
"src/"
|
|
19
|
+
]
|
|
20
|
+
}
|