homebridge-pitpat-treadmill 0.1.1 → 0.1.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
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.2] - 2026-10-09
6
+
7
+ ### Fixed
8
+
9
+ - Always use the hardware-validated 1.5 km/h minimum for remote starts.
10
+ - Prevent `RotationSpeed` changes from starting a stopped treadmill.
11
+ - Debounce HomeKit speed-slider updates to avoid BLE command floods.
12
+ - Handle disconnects during command confirmation without unhandled HomeKit write errors.
13
+ - Remove the unsupported `StatusFault` characteristic from the `Fanv2` service.
14
+
5
15
  ## [0.1.1] - 2026-10-09
6
16
 
7
17
  ### 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 (!active) {
74
- await this.bleManager.stopTreadmill();
70
+ if (this.activeCommand) {
71
+ await this.activeCommand;
75
72
  return;
76
73
  }
77
- if (!this.config.allowRemoteStart) {
78
- throw new this.api.hap.HapStatusError(-70412 /* this.api.hap.HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE */);
79
- }
80
- await this.bleManager.startTreadmill(this.targetSpeedKph);
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.bleManager.stopTreadmill();
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
- await this.bleManager.setSpeed(this.targetSpeedKph);
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
- throw new this.api.hap.HapStatusError(-70412 /* this.api.hap.HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE */);
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
- this.fanService.updateCharacteristic(this.api.hap.Characteristic.StatusFault, state === 'ready'
116
- ? this.api.hap.Characteristic.StatusFault.NO_FAULT
117
- : this.api.hap.Characteristic.StatusFault.GENERAL_FAULT);
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() {
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.1",
4
+ "version": "0.1.2",
5
5
  "description": "Local Homebridge integration for PitPat treadmills over Bluetooth Low Energy.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",