homebridge-withings-environment-data 0.1.3
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/.claude/settings.local.json +8 -0
- package/.nova/Configuration.json +3 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/config.schema.json +54 -0
- package/index.js +208 -0
- package/lib/discover.js +39 -0
- package/lib/login.js +142 -0
- package/package.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luuuud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Homebridge Withings Environment Data
|
|
2
|
+
|
|
3
|
+
**This Homebridge plugin has been 100% vibe coded with Claude.**
|
|
4
|
+
|
|
5
|
+
Exposes ambient CO2 (air quality) and room temperature readings from a
|
|
6
|
+
Withings WS-50 scale as HomeKit sensors:
|
|
7
|
+
|
|
8
|
+
- **CarbonDioxideSensor**: precise CO2 level in ppm, plus a normal/abnormal
|
|
9
|
+
detected alert based on a configurable threshold.
|
|
10
|
+
- **AirQualitySensor**: the same CO2 reading mapped to HomeKit's
|
|
11
|
+
Excellent/Good/Fair/Inferior/Poor category.
|
|
12
|
+
- **TemperatureSensor**: room temperature in °C.
|
|
13
|
+
|
|
14
|
+
This data isn't available through the official Withings API: a `getmeas`
|
|
15
|
+
call against the documented endpoint drops CO2/temperature even when
|
|
16
|
+
explicitly requested. Instead this plugin authenticates the same way the
|
|
17
|
+
Health Mate web app does (session cookies, not OAuth) and calls the same
|
|
18
|
+
internal endpoint the web app uses. That means it depends on undocumented
|
|
19
|
+
behavior of `account.withings.com` / `scalews.withings.com` and could break
|
|
20
|
+
if Withings changes them.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Install via the Homebridge Config UI: open the **Plugins** tab, search for
|
|
25
|
+
"Withings Environment Data", and click **Install**.
|
|
26
|
+
|
|
27
|
+
Or from the command line:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g homebridge-withings-environment-data
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then restart Homebridge and add the platform via the Config UI, or add it
|
|
34
|
+
manually to `config.json`.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
All settings are entered through the Homebridge Config UI (Plugins tab,
|
|
39
|
+
Withings Environment Data, Settings).
|
|
40
|
+
|
|
41
|
+
> **Note:** Homebridge stores your password and trust cookie value in plain
|
|
42
|
+
> text in its `config.json` file on disk. Anyone with access to that file,
|
|
43
|
+
> or to the Homebridge host itself, can read them. Treat that file with the
|
|
44
|
+
> same care as any other credentials store.
|
|
45
|
+
|
|
46
|
+
Fields:
|
|
47
|
+
|
|
48
|
+
- **Withings Email / Password**: your account credentials. Used only
|
|
49
|
+
against `account.withings.com`'s own login form.
|
|
50
|
+
- **Trust Cookie Name / Value**: see [Getting the trust
|
|
51
|
+
cookie](#getting-the-trust-cookie) below. As long as this is reused, the
|
|
52
|
+
plugin's automated logins skip the 2FA prompt.
|
|
53
|
+
- **Poll Interval (minutes)**: how often to fetch new readings (default 30,
|
|
54
|
+
matching the scale's own upload cadence).
|
|
55
|
+
- **CO2 Detected Threshold (ppm)**: ppm above which the CarbonDioxideSensor
|
|
56
|
+
reports "abnormal" (default 1000).
|
|
57
|
+
|
|
58
|
+
## When it stops working
|
|
59
|
+
|
|
60
|
+
If the Homebridge log shows a "session not trusted (landed on confirm_totp)"
|
|
61
|
+
error, the device-trust cookie has been invalidated (e.g. after a password
|
|
62
|
+
change, or Withings revoking trusted devices). The Home app will keep
|
|
63
|
+
showing the last known good reading, it won't go blank, but a fault
|
|
64
|
+
indicator appears on the sensors until this is fixed. Fix:
|
|
65
|
+
|
|
66
|
+
1. Recapture the trust cookie: see [Getting the trust
|
|
67
|
+
cookie](#getting-the-trust-cookie) below.
|
|
68
|
+
2. Update the **Trust Cookie Value** field in the plugin's Config UI
|
|
69
|
+
settings (the **Trust Cookie Name** itself should stay the same).
|
|
70
|
+
|
|
71
|
+
Any other poll failure (network error, unexpected response) is logged the
|
|
72
|
+
same way: an error in the Homebridge log, a fault indicator on the sensors,
|
|
73
|
+
and the previous readings left in place until the next successful poll.
|
|
74
|
+
|
|
75
|
+
## Getting the trust cookie
|
|
76
|
+
|
|
77
|
+
Both initial setup and recovering from an expired session need this. Capture
|
|
78
|
+
it once via DevTools:
|
|
79
|
+
|
|
80
|
+
1. Log into [account.withings.com](https://account.withings.com) in a
|
|
81
|
+
browser, making sure to check **"trust this device"** when prompted for
|
|
82
|
+
a 2FA code.
|
|
83
|
+
2. Open DevTools, Application tab, Cookies, `account.withings.com`.
|
|
84
|
+
3. Find the cookie named `2fa_token_<a long hex hash>`, then copy its full
|
|
85
|
+
name and its value. This is what actually signals "this device already
|
|
86
|
+
passed 2FA". Its name stays stable across logins even though its value
|
|
87
|
+
doesn't.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "WithingsEnvironmentData",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Exposes ambient CO2/air-quality and room temperature from a Withings WS-50 scale as HomeKit sensors. This data isn't available through the official Withings API, so this plugin authenticates the same way the Health Mate web app does. See the README for how to capture the config values below via browser DevTools.",
|
|
6
|
+
"schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"title": "Name",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"default": "Withings Environment",
|
|
13
|
+
"required": true
|
|
14
|
+
},
|
|
15
|
+
"email": {
|
|
16
|
+
"title": "Withings Email",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"required": true
|
|
19
|
+
},
|
|
20
|
+
"password": {
|
|
21
|
+
"title": "Withings Password",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"required": true,
|
|
24
|
+
"x-schema-form": {
|
|
25
|
+
"type": "password"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"trustCookieName": {
|
|
29
|
+
"title": "Trust Cookie Name",
|
|
30
|
+
"type": "string",
|
|
31
|
+
"required": true,
|
|
32
|
+
"description": "The cookie named 2fa_token_<long hex hash> under account.withings.com — copy its full name here"
|
|
33
|
+
},
|
|
34
|
+
"trustCookieValue": {
|
|
35
|
+
"title": "Trust Cookie Value",
|
|
36
|
+
"type": "string",
|
|
37
|
+
"required": true,
|
|
38
|
+
"description": "The value of that same 2fa_token_<hash> cookie"
|
|
39
|
+
},
|
|
40
|
+
"pollIntervalMinutes": {
|
|
41
|
+
"title": "Poll Interval (minutes)",
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"default": 30,
|
|
44
|
+
"minimum": 5
|
|
45
|
+
},
|
|
46
|
+
"co2DetectedThresholdPpm": {
|
|
47
|
+
"title": "CO2 Detected Threshold (ppm)",
|
|
48
|
+
"type": "integer",
|
|
49
|
+
"default": 1000,
|
|
50
|
+
"minimum": 400
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const { login } = require('./lib/login');
|
|
2
|
+
const { discoverDevice } = require('./lib/discover');
|
|
3
|
+
|
|
4
|
+
const MEASURE_URL = 'https://scalews.withings.com/cgi-bin/v2/measure';
|
|
5
|
+
// Reverse-engineered/unofficial: not part of the documented Withings API.
|
|
6
|
+
const MEASTYPE_CO2 = 35;
|
|
7
|
+
const MEASTYPE_TEMPERATURE = 12;
|
|
8
|
+
const APPNAME = 'hmw';
|
|
9
|
+
const APPPFM = 'web';
|
|
10
|
+
// Wide enough to reliably find at least one recent point even after a longer
|
|
11
|
+
// gap in scale uploads — only the single newest point per series is used.
|
|
12
|
+
const WINDOW_HOURS = 48;
|
|
13
|
+
|
|
14
|
+
const PLATFORM_NAME = 'WithingsEnvironmentData';
|
|
15
|
+
|
|
16
|
+
async function fetchLatest({ cookieHeader, sessionToken, deviceId, userId }) {
|
|
17
|
+
const enddate = Math.floor(Date.now() / 1000);
|
|
18
|
+
const startdate = enddate - WINDOW_HOURS * 3600;
|
|
19
|
+
|
|
20
|
+
const body = new URLSearchParams({
|
|
21
|
+
action: 'getmeashf',
|
|
22
|
+
deviceid: deviceId,
|
|
23
|
+
userid: userId,
|
|
24
|
+
meastype: `${MEASTYPE_CO2},${MEASTYPE_TEMPERATURE}`,
|
|
25
|
+
startdate: String(startdate),
|
|
26
|
+
enddate: String(enddate),
|
|
27
|
+
appname: APPNAME,
|
|
28
|
+
apppfm: APPPFM,
|
|
29
|
+
session_token: sessionToken,
|
|
30
|
+
}).toString();
|
|
31
|
+
|
|
32
|
+
const response = await fetch(MEASURE_URL, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'text/plain;charset=UTF-8',
|
|
36
|
+
Origin: 'https://app.withings.com',
|
|
37
|
+
Referer: 'https://app.withings.com/',
|
|
38
|
+
Cookie: cookieHeader,
|
|
39
|
+
},
|
|
40
|
+
body,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`Measure request failed with HTTP status ${response.status}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const json = await response.json();
|
|
48
|
+
if (json.status !== 0) {
|
|
49
|
+
throw new Error(`Measure request returned Withings status ${json.status}: ${JSON.stringify(json.error ?? json)}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const co2Series = json.body.series?.find((s) => s.type === MEASTYPE_CO2)?.data ?? [];
|
|
53
|
+
const tempSeries = json.body.series?.find((s) => s.type === MEASTYPE_TEMPERATURE)?.data ?? [];
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
// Both series are newest-first, so the first entry is the latest reading.
|
|
57
|
+
co2: co2Series.length > 0 ? co2Series[0].value : null,
|
|
58
|
+
temperature: tempSeries.length > 0 ? tempSeries[0].value : null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mapCo2ToAirQuality(ppm, AirQuality) {
|
|
63
|
+
if (ppm < 800) return AirQuality.EXCELLENT;
|
|
64
|
+
if (ppm < 1000) return AirQuality.GOOD;
|
|
65
|
+
if (ppm < 1500) return AirQuality.FAIR;
|
|
66
|
+
if (ppm < 2000) return AirQuality.INFERIOR;
|
|
67
|
+
return AirQuality.POOR;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class WithingsEnvironmentDataPlatform {
|
|
71
|
+
constructor(log, config, api) {
|
|
72
|
+
this.log = log;
|
|
73
|
+
this.config = config;
|
|
74
|
+
this.api = api;
|
|
75
|
+
this.Service = api.hap.Service;
|
|
76
|
+
this.Characteristic = api.hap.Characteristic;
|
|
77
|
+
this.accessories = [];
|
|
78
|
+
// Discovered automatically from the Withings association endpoint on first
|
|
79
|
+
// successful login, rather than requiring the user to hunt for these in
|
|
80
|
+
// DevTools — see lib/discover.js.
|
|
81
|
+
this.deviceId = null;
|
|
82
|
+
this.userId = null;
|
|
83
|
+
|
|
84
|
+
this.api.on('didFinishLaunching', () => this.discoverDevices());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
configureAccessory(accessory) {
|
|
88
|
+
this.log.info('Loading accessory from cache:', accessory.displayName);
|
|
89
|
+
this.accessories.push(accessory);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
discoverDevices() {
|
|
93
|
+
const uuid = this.api.hap.uuid.generate('withings-environment-data');
|
|
94
|
+
let accessory = this.accessories.find((acc) => acc.UUID === uuid);
|
|
95
|
+
|
|
96
|
+
if (!accessory) {
|
|
97
|
+
accessory = new this.api.platformAccessory(this.config.name || 'Withings Environment', uuid);
|
|
98
|
+
this.api.registerPlatformAccessories('homebridge-withings-environment-data', PLATFORM_NAME, [accessory]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.setupServices(accessory);
|
|
102
|
+
this.startPolling();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setupServices(accessory) {
|
|
106
|
+
this.infoService = accessory.getService(this.Service.AccessoryInformation);
|
|
107
|
+
if (this.infoService) {
|
|
108
|
+
this.infoService
|
|
109
|
+
.setCharacteristic(this.Characteristic.Manufacturer, 'Withings')
|
|
110
|
+
.setCharacteristic(this.Characteristic.Model, 'WS-50')
|
|
111
|
+
.setCharacteristic(this.Characteristic.SerialNumber, 'pending discovery');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.co2Service =
|
|
115
|
+
accessory.getService(this.Service.CarbonDioxideSensor) ||
|
|
116
|
+
accessory.addService(this.Service.CarbonDioxideSensor, 'CO2', 'co2');
|
|
117
|
+
|
|
118
|
+
this.airQualityService =
|
|
119
|
+
accessory.getService(this.Service.AirQualitySensor) ||
|
|
120
|
+
accessory.addService(this.Service.AirQualitySensor, 'Air Quality', 'air-quality');
|
|
121
|
+
|
|
122
|
+
this.temperatureService =
|
|
123
|
+
accessory.getService(this.Service.TemperatureSensor) ||
|
|
124
|
+
accessory.addService(this.Service.TemperatureSensor, 'Temperature', 'temperature');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
startPolling() {
|
|
128
|
+
const minutes = Number(this.config.pollIntervalMinutes);
|
|
129
|
+
const intervalMs = (Number.isFinite(minutes) && minutes > 0 ? minutes : 30) * 60 * 1000;
|
|
130
|
+
|
|
131
|
+
this.poll();
|
|
132
|
+
this.pollTimer = setInterval(() => this.poll(), intervalMs);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async poll() {
|
|
136
|
+
try {
|
|
137
|
+
const { cookieHeader, sessionToken } = await login(
|
|
138
|
+
this.config.email,
|
|
139
|
+
this.config.password,
|
|
140
|
+
this.config.trustCookieName,
|
|
141
|
+
this.config.trustCookieValue
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!this.deviceId || !this.userId) {
|
|
145
|
+
const discovered = await discoverDevice(cookieHeader);
|
|
146
|
+
this.deviceId = discovered.deviceId;
|
|
147
|
+
this.userId = discovered.userId;
|
|
148
|
+
this.log.info(`Discovered Withings device ${this.deviceId} for user ${this.userId}`);
|
|
149
|
+
if (this.infoService) {
|
|
150
|
+
this.infoService.setCharacteristic(this.Characteristic.SerialNumber, this.deviceId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { co2, temperature } = await fetchLatest({
|
|
155
|
+
cookieHeader,
|
|
156
|
+
sessionToken,
|
|
157
|
+
deviceId: this.deviceId,
|
|
158
|
+
userId: this.userId,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
this.applyReading(co2, temperature);
|
|
162
|
+
this.setFault(false);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Deliberately do not touch the value characteristics here — the Home app
|
|
165
|
+
// should keep showing the last known good reading, not go blank, when a
|
|
166
|
+
// poll fails (e.g. the trust cookie expired and login needs recapturing).
|
|
167
|
+
this.log.error(`Withings poll failed: ${err.message}`);
|
|
168
|
+
this.setFault(true);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
applyReading(co2, temperature) {
|
|
173
|
+
const threshold = Number(this.config.co2DetectedThresholdPpm);
|
|
174
|
+
const co2Threshold = Number.isFinite(threshold) && threshold > 0 ? threshold : 1000;
|
|
175
|
+
|
|
176
|
+
if (co2 !== null && co2 !== undefined) {
|
|
177
|
+
this.co2Service.updateCharacteristic(this.Characteristic.CarbonDioxideLevel, co2);
|
|
178
|
+
this.co2Service.updateCharacteristic(
|
|
179
|
+
this.Characteristic.CarbonDioxideDetected,
|
|
180
|
+
co2 > co2Threshold
|
|
181
|
+
? this.Characteristic.CarbonDioxideDetected.CO2_LEVELS_ABNORMAL
|
|
182
|
+
: this.Characteristic.CarbonDioxideDetected.CO2_LEVELS_NORMAL
|
|
183
|
+
);
|
|
184
|
+
this.airQualityService.updateCharacteristic(
|
|
185
|
+
this.Characteristic.AirQuality,
|
|
186
|
+
mapCo2ToAirQuality(co2, this.Characteristic.AirQuality)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (temperature !== null && temperature !== undefined) {
|
|
191
|
+
this.temperatureService.updateCharacteristic(this.Characteristic.CurrentTemperature, temperature);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
setFault(hasFault) {
|
|
196
|
+
const value = hasFault
|
|
197
|
+
? this.Characteristic.StatusFault.GENERAL_FAULT
|
|
198
|
+
: this.Characteristic.StatusFault.NO_FAULT;
|
|
199
|
+
|
|
200
|
+
this.co2Service.updateCharacteristic(this.Characteristic.StatusFault, value);
|
|
201
|
+
this.airQualityService.updateCharacteristic(this.Characteristic.StatusFault, value);
|
|
202
|
+
this.temperatureService.updateCharacteristic(this.Characteristic.StatusFault, value);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = (api) => {
|
|
207
|
+
api.registerPlatform(PLATFORM_NAME, WithingsEnvironmentDataPlatform);
|
|
208
|
+
};
|
package/lib/discover.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const ASSOCIATION_URL = 'https://scalews.withings.com/cgi-bin/association';
|
|
2
|
+
|
|
3
|
+
// Reverse-engineered/unofficial: not part of the documented Withings API.
|
|
4
|
+
// Lets the plugin find the account's scale deviceId/userId automatically
|
|
5
|
+
// after login, instead of requiring the user to hunt for them in DevTools.
|
|
6
|
+
async function discoverDevice(cookieHeader) {
|
|
7
|
+
const body = new URLSearchParams({ action: 'getbyaccountid', enrich: 't' }).toString();
|
|
8
|
+
|
|
9
|
+
const response = await fetch(ASSOCIATION_URL, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: {
|
|
12
|
+
'Content-Type': 'text/plain;charset=UTF-8',
|
|
13
|
+
Origin: 'https://app.withings.com',
|
|
14
|
+
Referer: 'https://app.withings.com/',
|
|
15
|
+
Cookie: cookieHeader,
|
|
16
|
+
},
|
|
17
|
+
body,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw new Error(`Device discovery request failed with HTTP status ${response.status}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const json = await response.json();
|
|
25
|
+
if (json.status !== 0) {
|
|
26
|
+
throw new Error(`Device discovery returned Withings status ${json.status}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const deviceId = json.body.associations?.[0]?.deviceid;
|
|
30
|
+
const userId = json.body.accounts?.[0]?.users?.[0]?.userid;
|
|
31
|
+
|
|
32
|
+
if (!deviceId || !userId) {
|
|
33
|
+
throw new Error('Could not determine deviceId/userId from the Withings association response');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { deviceId: String(deviceId), userId: String(userId) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { discoverDevice };
|
package/lib/login.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const ACCOUNT_BASE = 'https://account.withings.com';
|
|
2
|
+
const USER_AGENT =
|
|
3
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36';
|
|
4
|
+
|
|
5
|
+
class CookieJar {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.cookies = new Map();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
setFromResponse(response) {
|
|
11
|
+
const setCookieHeaders =
|
|
12
|
+
typeof response.headers.getSetCookie === 'function'
|
|
13
|
+
? response.headers.getSetCookie()
|
|
14
|
+
: response.headers.get('set-cookie')
|
|
15
|
+
? [response.headers.get('set-cookie')]
|
|
16
|
+
: [];
|
|
17
|
+
for (const header of setCookieHeaders) {
|
|
18
|
+
const pair = header.split(';', 1)[0];
|
|
19
|
+
const eqIdx = pair.indexOf('=');
|
|
20
|
+
if (eqIdx === -1) continue;
|
|
21
|
+
const name = pair.slice(0, eqIdx).trim();
|
|
22
|
+
const value = pair.slice(eqIdx + 1).trim();
|
|
23
|
+
// Withings clears cookies by setting value to the literal string "deleted"
|
|
24
|
+
// (alongside an expired Expires attribute) rather than omitting them.
|
|
25
|
+
if (value === 'deleted' || /expires=[^;]*1970/i.test(header)) {
|
|
26
|
+
this.cookies.delete(name);
|
|
27
|
+
} else {
|
|
28
|
+
this.cookies.set(name, value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(name, value) {
|
|
34
|
+
this.cookies.set(name, value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
toHeader() {
|
|
38
|
+
return Array.from(this.cookies.entries())
|
|
39
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
40
|
+
.join('; ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
findByPrefix(prefix) {
|
|
44
|
+
for (const [name, value] of this.cookies) {
|
|
45
|
+
if (name.startsWith(prefix)) return { name, value };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function requestFollowingRedirects(method, url, body, jar, maxHops = 10) {
|
|
52
|
+
let currentUrl = url;
|
|
53
|
+
let currentMethod = method;
|
|
54
|
+
let currentBody = body;
|
|
55
|
+
|
|
56
|
+
for (let hop = 0; hop <= maxHops; hop++) {
|
|
57
|
+
const headers = {
|
|
58
|
+
Cookie: jar.toHeader(),
|
|
59
|
+
'User-Agent': USER_AGENT,
|
|
60
|
+
Origin: ACCOUNT_BASE,
|
|
61
|
+
Referer: currentUrl,
|
|
62
|
+
};
|
|
63
|
+
if (currentBody) headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
64
|
+
|
|
65
|
+
const response = await fetch(currentUrl, {
|
|
66
|
+
method: currentMethod,
|
|
67
|
+
headers,
|
|
68
|
+
body: currentBody,
|
|
69
|
+
redirect: 'manual',
|
|
70
|
+
});
|
|
71
|
+
jar.setFromResponse(response);
|
|
72
|
+
|
|
73
|
+
if (response.status >= 300 && response.status < 400) {
|
|
74
|
+
const location = response.headers.get('location');
|
|
75
|
+
if (!location) return { response, finalUrl: currentUrl };
|
|
76
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
77
|
+
currentMethod = 'GET';
|
|
78
|
+
currentBody = undefined;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (response.status >= 400) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Withings login request to ${currentUrl} failed with status ${response.status}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { response, finalUrl: currentUrl };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw new Error(`Withings login exceeded ${maxHops} redirect hops starting from ${url}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function login(email, password, trustCookieName, trustCookieValue) {
|
|
95
|
+
const jar = new CookieJar();
|
|
96
|
+
// This cookie (scoped to account.withings.com) is the "this device already passed
|
|
97
|
+
// 2FA" marker — its name has stayed stable across many logins. It must be sent on
|
|
98
|
+
// the very first request of a fresh login attempt, before email/password. Confirmed
|
|
99
|
+
// empirically that no other cookie (e.g. w_uuid) is needed alongside it.
|
|
100
|
+
jar.set(trustCookieName, trustCookieValue);
|
|
101
|
+
|
|
102
|
+
await requestFollowingRedirects('GET', `${ACCOUNT_BASE}/`, undefined, jar);
|
|
103
|
+
|
|
104
|
+
const emailBody = new URLSearchParams({ email }).toString();
|
|
105
|
+
await requestFollowingRedirects(
|
|
106
|
+
'POST',
|
|
107
|
+
`${ACCOUNT_BASE}/new_workflow/login`,
|
|
108
|
+
emailBody,
|
|
109
|
+
jar
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const passwordBody = new URLSearchParams({ password }).toString();
|
|
113
|
+
const { finalUrl } = await requestFollowingRedirects(
|
|
114
|
+
'POST',
|
|
115
|
+
`${ACCOUNT_BASE}/new_workflow/password_check`,
|
|
116
|
+
passwordBody,
|
|
117
|
+
jar
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (finalUrl.includes('confirm_totp')) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"Withings session not trusted (landed on confirm_totp) — log in manually at " +
|
|
123
|
+
"https://account.withings.com with 'trust this device' checked during the 2FA step, " +
|
|
124
|
+
'then update the trustCookieValue plugin config field with a fresh value ' +
|
|
125
|
+
'(DevTools -> Application tab -> Cookies -> account.withings.com).'
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sessionTokenCookie = jar.findByPrefix('2fa_token_');
|
|
130
|
+
if (!sessionTokenCookie) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'Withings login completed but no 2fa_token_* cookie was found — the login flow may have changed.'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
cookieHeader: jar.toHeader(),
|
|
138
|
+
sessionToken: sessionTokenCookie.value,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { login };
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-withings-environment-data",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Homebridge plugin exposing ambient CO2/air-quality and room temperature readings from a Withings WS-50 scale as HomeKit sensors",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"homebridge-plugin"
|
|
8
|
+
],
|
|
9
|
+
"engines": {
|
|
10
|
+
"homebridge": ">=1.6.0",
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT"
|
|
14
|
+
}
|