ember-mug 0.3.1 → 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.
- package/dist/cli.js +33 -5
- package/dist/lib/bluetooth.js +80 -4
- package/dist/lib/debug.d.ts +3 -0
- package/dist/lib/debug.js +22 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
// Parse command line arguments BEFORE importing modules that initialize Bluetooth
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const showHelp = args.includes('--help') || args.includes('-h');
|
|
5
|
+
const debugMode = args.includes('--debug') || args.includes('-d');
|
|
6
|
+
if (showHelp) {
|
|
7
|
+
console.log(`
|
|
8
|
+
ember-mug - CLI for controlling Ember mugs via Bluetooth
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
ember-mug [options]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
-d, --debug Enable debug mode (outputs detailed Bluetooth logs)
|
|
15
|
+
-h, --help Show this help message
|
|
16
|
+
|
|
17
|
+
Environment variables:
|
|
18
|
+
EMBER_MOCK=true Run in mock mode (simulates mug for testing)
|
|
19
|
+
`);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
6
22
|
// ANSI escape codes for alternate screen buffer
|
|
7
23
|
const enterAltScreen = "\x1b[?1049h";
|
|
8
24
|
const exitAltScreen = "\x1b[?1049l";
|
|
@@ -26,13 +42,24 @@ process.stdout.on("resize", () => {
|
|
|
26
42
|
process.stdout.write(clearScreen);
|
|
27
43
|
});
|
|
28
44
|
async function main() {
|
|
45
|
+
// Dynamic imports to avoid initializing Bluetooth for --help
|
|
46
|
+
const { setDebugMode } = await import("./lib/debug.js");
|
|
47
|
+
// Enable debug mode if flag is set
|
|
48
|
+
if (debugMode) {
|
|
49
|
+
setDebugMode(true);
|
|
50
|
+
}
|
|
51
|
+
const React = await import("react");
|
|
52
|
+
const { render } = await import("ink");
|
|
53
|
+
const { App } = await import("./components/App.js");
|
|
54
|
+
const { isMockMode, setBluetoothManager } = await import("./lib/bluetooth.js");
|
|
29
55
|
// Initialize mock manager if in mock mode (set via EMBER_MOCK env var)
|
|
30
56
|
if (isMockMode()) {
|
|
31
57
|
const { getMockBluetoothManager } = await import("./lib/mock-bluetooth.js");
|
|
58
|
+
const { BluetoothManager } = await import("./lib/bluetooth.js");
|
|
32
59
|
setBluetoothManager(getMockBluetoothManager());
|
|
33
60
|
}
|
|
34
61
|
// Render the app
|
|
35
|
-
const app = render(
|
|
62
|
+
const app = render(React.createElement(App));
|
|
36
63
|
await app.waitUntilExit();
|
|
37
64
|
process.exit(0);
|
|
38
65
|
}
|
|
@@ -41,3 +68,4 @@ main().catch((error) => {
|
|
|
41
68
|
console.error("Failed to start:", error);
|
|
42
69
|
process.exit(1);
|
|
43
70
|
});
|
|
71
|
+
export {};
|
package/dist/lib/bluetooth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import noble from '@abandonware/noble';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import { LiquidState, TemperatureUnit, EMBER_SERVICE_UUID, EMBER_CHARACTERISTICS, MIN_TEMP_CELSIUS, MAX_TEMP_CELSIUS, } from './types.js';
|
|
4
|
+
import { debug } from './debug.js';
|
|
4
5
|
// Noble's state property exists but isn't properly typed
|
|
5
6
|
const getNobleState = () => noble.state;
|
|
6
7
|
export class BluetoothManager extends EventEmitter {
|
|
@@ -24,67 +25,91 @@ export class BluetoothManager extends EventEmitter {
|
|
|
24
25
|
this.setupNobleListeners();
|
|
25
26
|
}
|
|
26
27
|
setupNobleListeners() {
|
|
28
|
+
debug('Setting up Noble Bluetooth listeners');
|
|
27
29
|
noble.on('stateChange', (state) => {
|
|
30
|
+
debug('Bluetooth adapter state changed:', state);
|
|
28
31
|
if (state === 'poweredOn') {
|
|
29
|
-
|
|
32
|
+
debug('Bluetooth adapter is powered on and ready');
|
|
30
33
|
}
|
|
31
34
|
else {
|
|
35
|
+
debug('Bluetooth adapter not ready, state:', state);
|
|
32
36
|
this.emit('error', new Error(`Bluetooth state: ${state}`));
|
|
33
37
|
}
|
|
34
38
|
});
|
|
35
39
|
noble.on('discover', async (peripheral) => {
|
|
36
40
|
const name = peripheral.advertisement.localName || '';
|
|
41
|
+
const uuid = peripheral.uuid || 'unknown';
|
|
42
|
+
const rssi = peripheral.rssi || 'unknown';
|
|
43
|
+
debug(`Discovered device: name="${name}", uuid=${uuid}, rssi=${rssi}`);
|
|
37
44
|
if (name.toLowerCase().includes('ember')) {
|
|
45
|
+
debug(`Found Ember mug: "${name}"`);
|
|
38
46
|
this.emit('mugFound', name);
|
|
39
47
|
await this.stopScanning();
|
|
40
48
|
this.peripheral = peripheral;
|
|
49
|
+
debug('Starting connection process...');
|
|
41
50
|
await this.connectWithRetry();
|
|
42
51
|
}
|
|
43
52
|
});
|
|
44
53
|
}
|
|
45
54
|
async connectWithRetry(maxRetries = 3) {
|
|
46
55
|
let lastError = null;
|
|
56
|
+
debug(`Starting connection with up to ${maxRetries} retries`);
|
|
47
57
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
48
58
|
try {
|
|
59
|
+
debug(`Connection attempt ${attempt}/${maxRetries}`);
|
|
49
60
|
await this.connect();
|
|
61
|
+
debug('Connection successful!');
|
|
50
62
|
return; // Success
|
|
51
63
|
}
|
|
52
64
|
catch (err) {
|
|
53
65
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
66
|
+
debug(`Connection attempt ${attempt} failed:`, lastError.message);
|
|
54
67
|
if (attempt < maxRetries) {
|
|
55
68
|
// Wait before retrying (exponential backoff: 1s, 2s, 4s)
|
|
56
69
|
const delay = Math.pow(2, attempt - 1) * 1000;
|
|
70
|
+
debug(`Waiting ${delay}ms before retry...`);
|
|
57
71
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
58
72
|
}
|
|
59
73
|
}
|
|
60
74
|
}
|
|
61
75
|
// All retries failed
|
|
76
|
+
debug('All connection attempts failed');
|
|
62
77
|
this.emit('error', lastError || new Error('Connection failed after multiple attempts'));
|
|
63
78
|
}
|
|
64
79
|
async startScanning() {
|
|
80
|
+
debug('startScanning() called');
|
|
81
|
+
const currentState = getNobleState();
|
|
82
|
+
debug('Current Bluetooth adapter state:', currentState);
|
|
65
83
|
return new Promise((resolve, reject) => {
|
|
66
84
|
// Scan without service UUID filter - Ember mugs don't always advertise
|
|
67
85
|
// the service UUID in their advertisement packets. We filter by name instead.
|
|
68
86
|
const startScan = () => {
|
|
87
|
+
debug('Starting BLE scan (no service UUID filter, filtering by name)');
|
|
69
88
|
this.emit('scanning', true);
|
|
70
89
|
noble.startScanning([], false, (error) => {
|
|
71
90
|
if (error) {
|
|
91
|
+
debug('Failed to start scanning:', error.message);
|
|
72
92
|
reject(error);
|
|
73
93
|
}
|
|
74
94
|
else {
|
|
95
|
+
debug('BLE scan started successfully');
|
|
75
96
|
resolve();
|
|
76
97
|
}
|
|
77
98
|
});
|
|
78
99
|
};
|
|
79
|
-
if (
|
|
100
|
+
if (currentState === 'poweredOn') {
|
|
101
|
+
debug('Bluetooth adapter ready, starting scan immediately');
|
|
80
102
|
startScan();
|
|
81
103
|
}
|
|
82
104
|
else {
|
|
105
|
+
debug('Bluetooth adapter not ready, waiting for state change...');
|
|
83
106
|
noble.once('stateChange', (state) => {
|
|
107
|
+
debug('Bluetooth state changed to:', state);
|
|
84
108
|
if (state === 'poweredOn') {
|
|
85
109
|
startScan();
|
|
86
110
|
}
|
|
87
111
|
else {
|
|
112
|
+
debug('Bluetooth not available, cannot scan');
|
|
88
113
|
reject(new Error(`Bluetooth not available: ${state}`));
|
|
89
114
|
}
|
|
90
115
|
});
|
|
@@ -92,16 +117,25 @@ export class BluetoothManager extends EventEmitter {
|
|
|
92
117
|
});
|
|
93
118
|
}
|
|
94
119
|
async stopScanning() {
|
|
120
|
+
debug('Stopping BLE scan');
|
|
95
121
|
this.emit('scanning', false);
|
|
96
122
|
return new Promise((resolve) => {
|
|
97
|
-
noble.stopScanning(() =>
|
|
123
|
+
noble.stopScanning(() => {
|
|
124
|
+
debug('BLE scan stopped');
|
|
125
|
+
resolve();
|
|
126
|
+
});
|
|
98
127
|
});
|
|
99
128
|
}
|
|
100
129
|
async connect() {
|
|
101
130
|
if (!this.peripheral) {
|
|
131
|
+
debug('connect() called but no peripheral available');
|
|
102
132
|
throw new Error('No peripheral to connect to');
|
|
103
133
|
}
|
|
104
134
|
const CONNECTION_TIMEOUT = 10000; // 10 seconds timeout
|
|
135
|
+
const peripheralUuid = this.peripheral.uuid || 'unknown';
|
|
136
|
+
const peripheralName = this.peripheral.advertisement.localName || 'unknown';
|
|
137
|
+
debug(`Connecting to peripheral: name="${peripheralName}", uuid=${peripheralUuid}`);
|
|
138
|
+
debug(`Connection timeout set to ${CONNECTION_TIMEOUT}ms`);
|
|
105
139
|
return new Promise((resolve, reject) => {
|
|
106
140
|
let timeoutId = null;
|
|
107
141
|
let connected = false;
|
|
@@ -113,41 +147,56 @@ export class BluetoothManager extends EventEmitter {
|
|
|
113
147
|
};
|
|
114
148
|
timeoutId = setTimeout(() => {
|
|
115
149
|
if (!connected) {
|
|
150
|
+
debug(`Connection timeout reached after ${CONNECTION_TIMEOUT}ms`);
|
|
116
151
|
cleanup();
|
|
117
152
|
// Try to cancel the connection attempt
|
|
118
153
|
try {
|
|
154
|
+
debug('Attempting to disconnect peripheral after timeout');
|
|
119
155
|
this.peripheral?.disconnect();
|
|
120
156
|
}
|
|
121
|
-
catch {
|
|
157
|
+
catch (e) {
|
|
158
|
+
debug('Error during post-timeout disconnect:', e);
|
|
122
159
|
// Ignore disconnect errors during timeout
|
|
123
160
|
}
|
|
124
161
|
reject(new Error('Connection timed out. Make sure your mug is nearby and awake.'));
|
|
125
162
|
}
|
|
126
163
|
}, CONNECTION_TIMEOUT);
|
|
164
|
+
debug('Calling peripheral.connect()...');
|
|
127
165
|
this.peripheral.connect(async (error) => {
|
|
128
166
|
if (error) {
|
|
167
|
+
debug('peripheral.connect() callback received error:', error);
|
|
129
168
|
cleanup();
|
|
130
169
|
reject(new Error(`Failed to connect: ${error}`));
|
|
131
170
|
return;
|
|
132
171
|
}
|
|
172
|
+
debug('peripheral.connect() callback: connection established');
|
|
133
173
|
connected = true;
|
|
134
174
|
cleanup();
|
|
135
175
|
this.isConnected = true;
|
|
136
176
|
this.state.connected = true;
|
|
137
177
|
this.state.mugName = this.peripheral.advertisement.localName || 'Ember Mug';
|
|
178
|
+
debug(`Connected to mug: "${this.state.mugName}"`);
|
|
138
179
|
this.peripheral.once('disconnect', () => {
|
|
180
|
+
debug('Peripheral disconnect event received');
|
|
139
181
|
this.handleDisconnect();
|
|
140
182
|
});
|
|
141
183
|
try {
|
|
184
|
+
debug('Starting service/characteristic discovery...');
|
|
142
185
|
await this.discoverCharacteristics();
|
|
186
|
+
debug('Setting up push notifications...');
|
|
143
187
|
await this.setupNotifications();
|
|
188
|
+
debug('Reading initial values from mug...');
|
|
144
189
|
await this.readInitialValues();
|
|
190
|
+
debug('Starting polling loop...');
|
|
145
191
|
this.startPolling();
|
|
192
|
+
debug('Connection setup complete');
|
|
146
193
|
this.emit('connected');
|
|
147
194
|
this.emitState();
|
|
148
195
|
resolve();
|
|
149
196
|
}
|
|
150
197
|
catch (err) {
|
|
198
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
199
|
+
debug('Error during post-connection setup:', errorMsg);
|
|
151
200
|
this.handleDisconnect();
|
|
152
201
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
153
202
|
}
|
|
@@ -155,44 +204,56 @@ export class BluetoothManager extends EventEmitter {
|
|
|
155
204
|
});
|
|
156
205
|
}
|
|
157
206
|
async discoverCharacteristics() {
|
|
207
|
+
debug(`Discovering Ember service (UUID: ${EMBER_SERVICE_UUID})...`);
|
|
158
208
|
// First discover the Ember service specifically
|
|
159
209
|
const services = await new Promise((resolve, reject) => {
|
|
160
210
|
this.peripheral.discoverServices([EMBER_SERVICE_UUID], (error, services) => {
|
|
161
211
|
if (error) {
|
|
212
|
+
debug('Error discovering services:', error);
|
|
162
213
|
reject(error);
|
|
163
214
|
return;
|
|
164
215
|
}
|
|
216
|
+
debug(`Found ${services?.length || 0} services`);
|
|
165
217
|
resolve(services || []);
|
|
166
218
|
});
|
|
167
219
|
});
|
|
168
220
|
if (services.length === 0) {
|
|
221
|
+
debug('Ember service not found on device!');
|
|
169
222
|
throw new Error('Ember service not found on device');
|
|
170
223
|
}
|
|
171
224
|
// Then discover characteristics for that service
|
|
172
225
|
const emberService = services[0];
|
|
226
|
+
debug(`Discovering characteristics for Ember service...`);
|
|
173
227
|
return new Promise((resolve, reject) => {
|
|
174
228
|
emberService.discoverCharacteristics([], (error, characteristics) => {
|
|
175
229
|
if (error) {
|
|
230
|
+
debug('Error discovering characteristics:', error);
|
|
176
231
|
reject(error);
|
|
177
232
|
return;
|
|
178
233
|
}
|
|
234
|
+
debug(`Found ${characteristics?.length || 0} characteristics`);
|
|
179
235
|
for (const char of characteristics || []) {
|
|
180
236
|
const uuid = char.uuid.toLowerCase().replace(/-/g, '');
|
|
237
|
+
debug(` - Characteristic: ${uuid}`);
|
|
181
238
|
this.characteristics.set(uuid, char);
|
|
182
239
|
}
|
|
240
|
+
debug('Characteristic discovery complete');
|
|
183
241
|
resolve();
|
|
184
242
|
});
|
|
185
243
|
});
|
|
186
244
|
}
|
|
187
245
|
async setupNotifications() {
|
|
246
|
+
debug('Setting up push event notifications...');
|
|
188
247
|
const pushEventsChar = this.characteristics.get(EMBER_CHARACTERISTICS.PUSH_EVENTS);
|
|
189
248
|
if (pushEventsChar) {
|
|
190
249
|
return new Promise((resolve, reject) => {
|
|
191
250
|
pushEventsChar.subscribe((error) => {
|
|
192
251
|
if (error) {
|
|
252
|
+
debug('Error subscribing to push events:', error);
|
|
193
253
|
reject(error);
|
|
194
254
|
return;
|
|
195
255
|
}
|
|
256
|
+
debug('Successfully subscribed to push events');
|
|
196
257
|
pushEventsChar.on('data', (data) => {
|
|
197
258
|
this.handlePushEvent(data);
|
|
198
259
|
});
|
|
@@ -200,30 +261,42 @@ export class BluetoothManager extends EventEmitter {
|
|
|
200
261
|
});
|
|
201
262
|
});
|
|
202
263
|
}
|
|
264
|
+
else {
|
|
265
|
+
debug('Push events characteristic not found, skipping notifications');
|
|
266
|
+
}
|
|
203
267
|
}
|
|
204
268
|
handlePushEvent(data) {
|
|
205
269
|
const eventType = data[0];
|
|
270
|
+
debug(`Push event received: type=${eventType}`);
|
|
206
271
|
switch (eventType) {
|
|
207
272
|
case 1: // Battery changed
|
|
273
|
+
debug('Push event: Battery changed');
|
|
208
274
|
this.readBattery();
|
|
209
275
|
break;
|
|
210
276
|
case 2: // Started charging
|
|
277
|
+
debug('Push event: Started charging');
|
|
211
278
|
this.state.isCharging = true;
|
|
212
279
|
this.emitState();
|
|
213
280
|
break;
|
|
214
281
|
case 3: // Stopped charging
|
|
282
|
+
debug('Push event: Stopped charging');
|
|
215
283
|
this.state.isCharging = false;
|
|
216
284
|
this.emitState();
|
|
217
285
|
break;
|
|
218
286
|
case 4: // Target temp changed
|
|
287
|
+
debug('Push event: Target temp changed');
|
|
219
288
|
this.readTargetTemp();
|
|
220
289
|
break;
|
|
221
290
|
case 5: // Current temp changed
|
|
291
|
+
debug('Push event: Current temp changed');
|
|
222
292
|
this.readCurrentTemp();
|
|
223
293
|
break;
|
|
224
294
|
case 8: // Liquid state changed
|
|
295
|
+
debug('Push event: Liquid state changed');
|
|
225
296
|
this.readLiquidState();
|
|
226
297
|
break;
|
|
298
|
+
default:
|
|
299
|
+
debug(`Push event: Unknown type ${eventType}`);
|
|
227
300
|
}
|
|
228
301
|
}
|
|
229
302
|
async readInitialValues() {
|
|
@@ -246,13 +319,16 @@ export class BluetoothManager extends EventEmitter {
|
|
|
246
319
|
}, 2000);
|
|
247
320
|
}
|
|
248
321
|
handleDisconnect() {
|
|
322
|
+
debug('Handling disconnect...');
|
|
249
323
|
this.isConnected = false;
|
|
250
324
|
this.state.connected = false;
|
|
251
325
|
if (this.pollInterval) {
|
|
326
|
+
debug('Clearing polling interval');
|
|
252
327
|
clearInterval(this.pollInterval);
|
|
253
328
|
this.pollInterval = null;
|
|
254
329
|
}
|
|
255
330
|
this.characteristics.clear();
|
|
331
|
+
debug('Disconnect complete, emitting disconnected event');
|
|
256
332
|
this.emit('disconnected');
|
|
257
333
|
this.emitState();
|
|
258
334
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Debug logging utility
|
|
2
|
+
// When enabled, outputs detailed logs to stderr (so it doesn't interfere with Ink UI)
|
|
3
|
+
let debugEnabled = false;
|
|
4
|
+
export function setDebugMode(enabled) {
|
|
5
|
+
debugEnabled = enabled;
|
|
6
|
+
if (enabled) {
|
|
7
|
+
debug('Debug mode enabled');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function isDebugMode() {
|
|
11
|
+
return debugEnabled;
|
|
12
|
+
}
|
|
13
|
+
export function debug(message, ...args) {
|
|
14
|
+
if (!debugEnabled)
|
|
15
|
+
return;
|
|
16
|
+
const timestamp = new Date().toISOString();
|
|
17
|
+
const formattedArgs = args.length > 0
|
|
18
|
+
? ' ' + args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ')
|
|
19
|
+
: '';
|
|
20
|
+
// Write to stderr so it doesn't interfere with Ink's stdout rendering
|
|
21
|
+
process.stderr.write(`[${timestamp}] [DEBUG] ${message}${formattedArgs}\n`);
|
|
22
|
+
}
|