homebridge-pitpat-treadmill 0.1.1 → 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/CHANGELOG.md +17 -0
- package/dist/accessories/treadmillAccessory.d.ts +7 -1
- package/dist/accessories/treadmillAccessory.js +74 -22
- package/dist/bluetooth/bleManager.js +8 -0
- package/dist/bluetooth/nobleTransport.d.ts +4 -0
- package/dist/bluetooth/nobleTransport.js +27 -1
- package/dist/platform.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. The project follows Semantic Versioning.
|
|
4
4
|
|
|
5
|
+
## [0.1.3] - 2026-10-09
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Attempt one controlled HCI controller reset when Noble remains outside the `poweredOn` state after a BLE link timeout.
|
|
10
|
+
- Preserve normal reconnect backoff when the adapter cannot recover.
|
|
11
|
+
|
|
12
|
+
## [0.1.2] - 2026-10-09
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Always use the hardware-validated 1.5 km/h minimum for remote starts.
|
|
17
|
+
- Prevent `RotationSpeed` changes from starting a stopped treadmill.
|
|
18
|
+
- Debounce HomeKit speed-slider updates to avoid BLE command floods.
|
|
19
|
+
- Handle disconnects during command confirmation without unhandled HomeKit write errors.
|
|
20
|
+
- Remove the unsupported `StatusFault` characteristic from the `Fanv2` service.
|
|
21
|
+
|
|
5
22
|
## [0.1.1] - 2026-10-09
|
|
6
23
|
|
|
7
24
|
### Changed
|
|
@@ -11,14 +11,20 @@ export declare class TreadmillAccessory {
|
|
|
11
11
|
private readonly fanService;
|
|
12
12
|
private readonly telemetryService;
|
|
13
13
|
private targetSpeedKph;
|
|
14
|
+
private activeCommand;
|
|
15
|
+
private pendingSpeedKph;
|
|
16
|
+
private speedUpdateTimer;
|
|
14
17
|
constructor(accessory: PlatformAccessory, bleManager: BleManager, config: ValidatedConfig, api: API, log: Logger);
|
|
15
18
|
private isActive;
|
|
16
19
|
private currentSpeedPercentage;
|
|
17
20
|
private currentFanState;
|
|
18
|
-
private statusFault;
|
|
19
21
|
private setActive;
|
|
20
22
|
private setSpeed;
|
|
23
|
+
private applyActive;
|
|
24
|
+
private scheduleSpeedUpdate;
|
|
25
|
+
private cancelPendingSpeedUpdate;
|
|
21
26
|
private updateStatus;
|
|
22
27
|
private updateConnectionState;
|
|
28
|
+
private refreshControlCharacteristics;
|
|
23
29
|
private effectiveMaximumSpeed;
|
|
24
30
|
}
|
|
@@ -10,6 +10,9 @@ export class TreadmillAccessory {
|
|
|
10
10
|
fanService;
|
|
11
11
|
telemetryService;
|
|
12
12
|
targetSpeedKph = MINIMUM_COMMAND_SPEED_KPH;
|
|
13
|
+
activeCommand;
|
|
14
|
+
pendingSpeedKph;
|
|
15
|
+
speedUpdateTimer;
|
|
13
16
|
constructor(accessory, bleManager, config, api, log) {
|
|
14
17
|
this.accessory = accessory;
|
|
15
18
|
this.bleManager = bleManager;
|
|
@@ -34,7 +37,6 @@ export class TreadmillAccessory {
|
|
|
34
37
|
.onGet(() => this.currentSpeedPercentage())
|
|
35
38
|
.onSet(async (value) => this.setSpeed(value));
|
|
36
39
|
this.fanService.getCharacteristic(Characteristic.CurrentFanState).onGet(() => this.currentFanState());
|
|
37
|
-
this.fanService.getCharacteristic(Characteristic.StatusFault).onGet(() => this.statusFault());
|
|
38
40
|
accessory
|
|
39
41
|
.getService(Service.AccessoryInformation)
|
|
40
42
|
?.setCharacteristic(Characteristic.Manufacturer, 'PitPat (unofficial integration)')
|
|
@@ -63,40 +65,81 @@ export class TreadmillAccessory {
|
|
|
63
65
|
}
|
|
64
66
|
return this.api.hap.Characteristic.CurrentFanState.INACTIVE;
|
|
65
67
|
}
|
|
66
|
-
statusFault() {
|
|
67
|
-
return this.bleManager.state === 'ready'
|
|
68
|
-
? this.api.hap.Characteristic.StatusFault.NO_FAULT
|
|
69
|
-
: this.api.hap.Characteristic.StatusFault.GENERAL_FAULT;
|
|
70
|
-
}
|
|
71
68
|
async setActive(value) {
|
|
72
69
|
const active = Number(value) === this.api.hap.Characteristic.Active.ACTIVE;
|
|
73
|
-
if (
|
|
74
|
-
await this.
|
|
70
|
+
if (this.activeCommand) {
|
|
71
|
+
await this.activeCommand;
|
|
75
72
|
return;
|
|
76
73
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
this.activeCommand = this.applyActive(active)
|
|
75
|
+
.catch((error) => {
|
|
76
|
+
this.log.warn(`[PiTPAT] HomeKit ${active ? 'start' : 'stop'} request was not applied: ${errorMessage(error)}`);
|
|
77
|
+
this.refreshControlCharacteristics();
|
|
78
|
+
})
|
|
79
|
+
.finally(() => {
|
|
80
|
+
this.activeCommand = undefined;
|
|
81
|
+
});
|
|
82
|
+
await this.activeCommand;
|
|
81
83
|
}
|
|
82
84
|
async setSpeed(value) {
|
|
83
85
|
const percentage = Number(value);
|
|
84
86
|
if (percentage === 0) {
|
|
85
|
-
await this.
|
|
87
|
+
await this.setActive(this.api.hap.Characteristic.Active.INACTIVE);
|
|
86
88
|
return;
|
|
87
89
|
}
|
|
88
90
|
const deviceMaximum = this.bleManager.status?.maximumSpeedKph ?? this.config.maximumSpeedKph;
|
|
89
91
|
const requested = percentageToSpeed(percentage, this.effectiveMaximumSpeed(deviceMaximum));
|
|
90
92
|
this.targetSpeedKph = Math.max(MINIMUM_COMMAND_SPEED_KPH, requested);
|
|
91
93
|
if (this.bleManager.status?.runningState === RunningState.Running) {
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
else if (this.config.allowRemoteStart) {
|
|
95
|
-
await this.bleManager.startTreadmill(this.targetSpeedKph);
|
|
94
|
+
this.scheduleSpeedUpdate(this.targetSpeedKph);
|
|
96
95
|
}
|
|
97
96
|
else {
|
|
98
|
-
|
|
97
|
+
// RotationSpeed must never start a stopped treadmill. Active is the only
|
|
98
|
+
// HomeKit control allowed to request a remote start.
|
|
99
|
+
this.refreshControlCharacteristics();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async applyActive(active) {
|
|
103
|
+
if (!active) {
|
|
104
|
+
this.cancelPendingSpeedUpdate();
|
|
105
|
+
if (this.bleManager.status?.runningState === RunningState.Stopped) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await this.bleManager.stopTreadmill();
|
|
109
|
+
return;
|
|
99
110
|
}
|
|
111
|
+
if (!this.config.allowRemoteStart) {
|
|
112
|
+
throw new Error('remote start is disabled by configuration');
|
|
113
|
+
}
|
|
114
|
+
if (this.isActive() === this.api.hap.Characteristic.Active.ACTIVE) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// A remote start always uses the hardware-validated minimum. A separate,
|
|
118
|
+
// confirmed speed request may increase it only after Running is reported.
|
|
119
|
+
this.targetSpeedKph = MINIMUM_COMMAND_SPEED_KPH;
|
|
120
|
+
await this.bleManager.startTreadmill(MINIMUM_COMMAND_SPEED_KPH);
|
|
121
|
+
}
|
|
122
|
+
scheduleSpeedUpdate(speedKph) {
|
|
123
|
+
this.pendingSpeedKph = speedKph;
|
|
124
|
+
clearTimeout(this.speedUpdateTimer);
|
|
125
|
+
this.speedUpdateTimer = setTimeout(() => {
|
|
126
|
+
this.speedUpdateTimer = undefined;
|
|
127
|
+
const pendingSpeedKph = this.pendingSpeedKph;
|
|
128
|
+
this.pendingSpeedKph = undefined;
|
|
129
|
+
if (pendingSpeedKph === undefined || this.bleManager.status?.runningState !== RunningState.Running) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
void this.bleManager.setSpeed(pendingSpeedKph).catch((error) => {
|
|
133
|
+
this.log.warn(`[PiTPAT] HomeKit speed request was not applied: ${errorMessage(error)}`);
|
|
134
|
+
this.refreshControlCharacteristics();
|
|
135
|
+
});
|
|
136
|
+
}, 750);
|
|
137
|
+
this.speedUpdateTimer.unref();
|
|
138
|
+
}
|
|
139
|
+
cancelPendingSpeedUpdate() {
|
|
140
|
+
clearTimeout(this.speedUpdateTimer);
|
|
141
|
+
this.speedUpdateTimer = undefined;
|
|
142
|
+
this.pendingSpeedKph = undefined;
|
|
100
143
|
}
|
|
101
144
|
updateStatus({ status }) {
|
|
102
145
|
const { Characteristic } = this.api.hap;
|
|
@@ -105,19 +148,28 @@ export class TreadmillAccessory {
|
|
|
105
148
|
this.fanService.updateCharacteristic(Characteristic.Active, this.isActive());
|
|
106
149
|
this.fanService.updateCharacteristic(Characteristic.RotationSpeed, this.currentSpeedPercentage());
|
|
107
150
|
this.fanService.updateCharacteristic(Characteristic.CurrentFanState, this.currentFanState());
|
|
108
|
-
this.fanService.updateCharacteristic(Characteristic.StatusFault, Characteristic.StatusFault.NO_FAULT);
|
|
109
151
|
this.telemetryService.updateStatus(status);
|
|
110
152
|
if (this.config.debug) {
|
|
111
153
|
this.log.debug(`[PiTPAT] Speed updated: ${status.currentSpeedKph.toFixed(1)} km/h, state ${status.runningState}`);
|
|
112
154
|
}
|
|
113
155
|
}
|
|
114
156
|
updateConnectionState(state) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
157
|
+
if (state !== 'ready') {
|
|
158
|
+
this.cancelPendingSpeedUpdate();
|
|
159
|
+
this.refreshControlCharacteristics();
|
|
160
|
+
}
|
|
118
161
|
this.telemetryService.updateConnection(state);
|
|
119
162
|
}
|
|
163
|
+
refreshControlCharacteristics() {
|
|
164
|
+
const { Characteristic } = this.api.hap;
|
|
165
|
+
this.fanService.updateCharacteristic(Characteristic.Active, this.isActive());
|
|
166
|
+
this.fanService.updateCharacteristic(Characteristic.RotationSpeed, this.currentSpeedPercentage());
|
|
167
|
+
this.fanService.updateCharacteristic(Characteristic.CurrentFanState, this.currentFanState());
|
|
168
|
+
}
|
|
120
169
|
effectiveMaximumSpeed(deviceMaximumSpeedKph) {
|
|
121
170
|
return Math.min(this.config.maximumSpeedKph, deviceMaximumSpeedKph);
|
|
122
171
|
}
|
|
123
172
|
}
|
|
173
|
+
function errorMessage(error) {
|
|
174
|
+
return error instanceof Error ? error.message : String(error);
|
|
175
|
+
}
|
|
@@ -172,15 +172,23 @@ export class BleManager extends EventEmitter {
|
|
|
172
172
|
resolve(event.status);
|
|
173
173
|
}
|
|
174
174
|
};
|
|
175
|
+
const onConnectionState = (state) => {
|
|
176
|
+
if (state === 'disconnected' || state === 'disconnecting') {
|
|
177
|
+
cleanup();
|
|
178
|
+
reject(new Error('BLE disconnected while waiting for treadmill confirmation'));
|
|
179
|
+
}
|
|
180
|
+
};
|
|
175
181
|
const cleanup = () => {
|
|
176
182
|
clearTimeout(timer);
|
|
177
183
|
this.removeListener('status', onStatus);
|
|
184
|
+
this.removeListener('connectionState', onConnectionState);
|
|
178
185
|
};
|
|
179
186
|
const timer = setTimeout(() => {
|
|
180
187
|
cleanup();
|
|
181
188
|
reject(new Error('timed out waiting for treadmill confirmation'));
|
|
182
189
|
}, timeoutMs);
|
|
183
190
|
this.on('status', onStatus);
|
|
191
|
+
this.on('connectionState', onConnectionState);
|
|
184
192
|
});
|
|
185
193
|
}
|
|
186
194
|
scheduleReconnect() {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import type { Logger } from 'homebridge';
|
|
1
2
|
import type { BleConnection, BleTransport } from './transport.js';
|
|
2
3
|
export declare class NobleTransport implements BleTransport {
|
|
4
|
+
private readonly log;
|
|
3
5
|
private noble?;
|
|
6
|
+
constructor(log: Pick<Logger, 'warn'>);
|
|
4
7
|
private loadNoble;
|
|
5
8
|
connect(deviceIdentifier: string, timeoutMs: number): Promise<BleConnection>;
|
|
9
|
+
private ensurePoweredOn;
|
|
6
10
|
private findPeripheral;
|
|
7
11
|
}
|
|
@@ -39,7 +39,11 @@ class NobleConnection {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
export class NobleTransport {
|
|
42
|
+
log;
|
|
42
43
|
noble;
|
|
44
|
+
constructor(log) {
|
|
45
|
+
this.log = log;
|
|
46
|
+
}
|
|
43
47
|
async loadNoble() {
|
|
44
48
|
if (!this.noble) {
|
|
45
49
|
const module = await import('@stoprocent/noble');
|
|
@@ -49,7 +53,7 @@ export class NobleTransport {
|
|
|
49
53
|
}
|
|
50
54
|
async connect(deviceIdentifier, timeoutMs) {
|
|
51
55
|
const noble = await this.loadNoble();
|
|
52
|
-
await
|
|
56
|
+
await this.ensurePoweredOn(noble, timeoutMs);
|
|
53
57
|
const peripheral = await this.findPeripheral(noble, deviceIdentifier, timeoutMs);
|
|
54
58
|
await peripheral.connectAsync();
|
|
55
59
|
try {
|
|
@@ -71,6 +75,25 @@ export class NobleTransport {
|
|
|
71
75
|
throw error;
|
|
72
76
|
}
|
|
73
77
|
}
|
|
78
|
+
async ensurePoweredOn(noble, timeoutMs) {
|
|
79
|
+
try {
|
|
80
|
+
await noble.waitForPoweredOnAsync(timeoutMs);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (noble.state === 'unauthorized' || noble.state === 'unsupported') {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
this.log.warn(`[PiTPAT] BLE adapter state is ${noble.state}; attempting one controller reset`);
|
|
88
|
+
noble.reset();
|
|
89
|
+
try {
|
|
90
|
+
await noble.waitForPoweredOnAsync(Math.min(timeoutMs, 10_000));
|
|
91
|
+
}
|
|
92
|
+
catch (recoveryError) {
|
|
93
|
+
throw new Error(`BLE adapter did not recover after reset (state ${noble.state}): ${errorMessage(recoveryError)}`, { cause: recoveryError });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
74
97
|
async findPeripheral(noble, configuredIdentifier, timeoutMs) {
|
|
75
98
|
const expected = normalizeIdentifier(configuredIdentifier);
|
|
76
99
|
return await new Promise((resolve, reject) => {
|
|
@@ -111,3 +134,6 @@ export class NobleTransport {
|
|
|
111
134
|
});
|
|
112
135
|
}
|
|
113
136
|
}
|
|
137
|
+
function errorMessage(error) {
|
|
138
|
+
return error instanceof Error ? error.message : String(error);
|
|
139
|
+
}
|
package/dist/platform.js
CHANGED
|
@@ -40,7 +40,7 @@ export class PitPatPlatform {
|
|
|
40
40
|
accessory = new this.api.platformAccessory(config.name, uuid, 3 /* this.api.hap.Categories.FAN */);
|
|
41
41
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
42
42
|
}
|
|
43
|
-
this.bleManager = new BleManager(config, new NobleTransport(), this.log);
|
|
43
|
+
this.bleManager = new BleManager(config, new NobleTransport(this.log), this.log);
|
|
44
44
|
new TreadmillAccessory(accessory, this.bleManager, config, this.api, this.log);
|
|
45
45
|
void this.bleManager.start().catch(() => undefined);
|
|
46
46
|
const staleAccessories = [...this.cachedAccessories.values()].filter((cached) => cached.UUID !== uuid);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-pitpat-treadmill",
|
|
3
3
|
"displayName": "PitPat Treadmill",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.3",
|
|
5
5
|
"description": "Local Homebridge integration for PitPat treadmills over Bluetooth Low Energy.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|