ember-mug 0.3.1 → 0.3.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/dist/cli.js CHANGED
@@ -1,43 +1,129 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
- import { render } from "ink";
4
- import { App } from "./components/App.js";
5
- import { isMockMode, setBluetoothManager } from "./lib/bluetooth.js";
6
- // ANSI escape codes for alternate screen buffer
7
- const enterAltScreen = "\x1b[?1049h";
8
- const exitAltScreen = "\x1b[?1049l";
9
- const hideCursor = "\x1b[?25l";
10
- const showCursor = "\x1b[?25h";
11
- const clearScreen = "\x1b[2J\x1b[H";
12
- // Enter alternate screen buffer and hide cursor
13
- process.stdout.write(enterAltScreen + hideCursor + clearScreen);
14
- // Handle graceful shutdown - restore terminal state
15
- const cleanup = () => {
16
- process.stdout.write(showCursor + exitAltScreen);
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 (console output with 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
+ `);
17
20
  process.exit(0);
18
- };
19
- process.on("SIGINT", cleanup);
20
- process.on("SIGTERM", cleanup);
21
- process.on("exit", () => {
22
- process.stdout.write(showCursor + exitAltScreen);
23
- });
24
- // Handle terminal resize - clear screen to prevent artifacts
25
- process.stdout.on("resize", () => {
26
- process.stdout.write(clearScreen);
27
- });
28
- async function main() {
29
- // Initialize mock manager if in mock mode (set via EMBER_MOCK env var)
21
+ }
22
+ // Debug mode: run without Ink UI, just console output
23
+ async function runDebugMode() {
24
+ const { setDebugMode } = await import("./lib/debug.js");
25
+ setDebugMode(true);
26
+ console.log("Starting ember-mug in debug mode...\n");
27
+ const { getBluetoothManager, isMockMode, setBluetoothManager } = await import("./lib/bluetooth.js");
28
+ // Initialize mock manager if in mock mode
30
29
  if (isMockMode()) {
30
+ console.log("Running in MOCK mode\n");
31
31
  const { getMockBluetoothManager } = await import("./lib/mock-bluetooth.js");
32
+ const { BluetoothManager } = await import("./lib/bluetooth.js");
32
33
  setBluetoothManager(getMockBluetoothManager());
33
34
  }
34
- // Render the app
35
- const app = render(_jsx(App, {}));
36
- await app.waitUntilExit();
37
- process.exit(0);
35
+ const manager = getBluetoothManager();
36
+ // Listen to all events and log them
37
+ manager.on('scanning', (isScanning) => {
38
+ console.log(`[EVENT] Scanning: ${isScanning}`);
39
+ });
40
+ manager.on('mugFound', (name) => {
41
+ console.log(`[EVENT] Mug found: ${name}`);
42
+ });
43
+ manager.on('connected', () => {
44
+ console.log(`[EVENT] Connected!`);
45
+ });
46
+ manager.on('disconnected', () => {
47
+ console.log(`[EVENT] Disconnected`);
48
+ });
49
+ manager.on('error', (error) => {
50
+ console.error(`[EVENT] Error: ${error.message}`);
51
+ });
52
+ manager.on('stateChange', (state) => {
53
+ console.log(`[EVENT] State changed:`, JSON.stringify(state, null, 2));
54
+ });
55
+ // Handle graceful shutdown
56
+ process.on("SIGINT", () => {
57
+ console.log("\nShutting down...");
58
+ manager.disconnect();
59
+ process.exit(0);
60
+ });
61
+ // Start scanning
62
+ console.log("Starting Bluetooth scan...\n");
63
+ try {
64
+ await manager.startScanning();
65
+ }
66
+ catch (error) {
67
+ console.error("Failed to start scanning:", error);
68
+ process.exit(1);
69
+ }
70
+ // Keep the process running
71
+ await new Promise(() => { });
72
+ }
73
+ // Normal mode: run with Ink UI
74
+ async function runNormalMode() {
75
+ // ANSI escape codes for alternate screen buffer
76
+ const enterAltScreen = "\x1b[?1049h";
77
+ const exitAltScreen = "\x1b[?1049l";
78
+ const hideCursor = "\x1b[?25l";
79
+ const showCursor = "\x1b[?25h";
80
+ const clearScreen = "\x1b[2J\x1b[H";
81
+ // Enter alternate screen buffer and hide cursor
82
+ process.stdout.write(enterAltScreen + hideCursor + clearScreen);
83
+ // Handle graceful shutdown - restore terminal state
84
+ const cleanup = () => {
85
+ process.stdout.write(showCursor + exitAltScreen);
86
+ process.exit(0);
87
+ };
88
+ process.on("SIGINT", cleanup);
89
+ process.on("SIGTERM", cleanup);
90
+ process.on("exit", () => {
91
+ process.stdout.write(showCursor + exitAltScreen);
92
+ });
93
+ // Handle terminal resize - clear screen to prevent artifacts
94
+ process.stdout.on("resize", () => {
95
+ process.stdout.write(clearScreen);
96
+ });
97
+ try {
98
+ const React = await import("react");
99
+ const { render } = await import("ink");
100
+ const { App } = await import("./components/App.js");
101
+ const { isMockMode, setBluetoothManager } = await import("./lib/bluetooth.js");
102
+ // Initialize mock manager if in mock mode (set via EMBER_MOCK env var)
103
+ if (isMockMode()) {
104
+ const { getMockBluetoothManager } = await import("./lib/mock-bluetooth.js");
105
+ const { BluetoothManager } = await import("./lib/bluetooth.js");
106
+ setBluetoothManager(getMockBluetoothManager());
107
+ }
108
+ // Render the app
109
+ const app = render(React.createElement(App));
110
+ await app.waitUntilExit();
111
+ process.exit(0);
112
+ }
113
+ catch (error) {
114
+ process.stdout.write(showCursor + exitAltScreen);
115
+ console.error("Failed to start:", error);
116
+ process.exit(1);
117
+ }
118
+ }
119
+ // Run appropriate mode
120
+ if (debugMode) {
121
+ runDebugMode().catch((error) => {
122
+ console.error("Failed to start:", error);
123
+ process.exit(1);
124
+ });
125
+ }
126
+ else {
127
+ runNormalMode();
38
128
  }
39
- main().catch((error) => {
40
- process.stdout.write(showCursor + exitAltScreen);
41
- console.error("Failed to start:", error);
42
- process.exit(1);
43
- });
129
+ export {};
@@ -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
- // Ready for scanning
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 (getNobleState() === 'poweredOn') {
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(() => resolve());
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,3 @@
1
+ export declare function setDebugMode(enabled: boolean): void;
2
+ export declare function isDebugMode(): boolean;
3
+ export declare function debug(message: string, ...args: unknown[]): void;
@@ -0,0 +1,21 @@
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
+ console.log(`[${timestamp}] [DEBUG] ${message}${formattedArgs}`);
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-mug",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "A CLI app for controlling Ember mugs via Bluetooth",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",