ember-mug 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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +13 -0
  5. package/dist/components/App.d.ts +2 -0
  6. package/dist/components/App.js +120 -0
  7. package/dist/components/BatteryDisplay.d.ts +9 -0
  8. package/dist/components/BatteryDisplay.js +25 -0
  9. package/dist/components/ColorControl.d.ts +9 -0
  10. package/dist/components/ColorControl.js +71 -0
  11. package/dist/components/ConnectionStatus.d.ts +10 -0
  12. package/dist/components/ConnectionStatus.js +9 -0
  13. package/dist/components/Header.d.ts +7 -0
  14. package/dist/components/Header.js +5 -0
  15. package/dist/components/HelpDisplay.d.ts +6 -0
  16. package/dist/components/HelpDisplay.js +5 -0
  17. package/dist/components/Presets.d.ts +11 -0
  18. package/dist/components/Presets.js +19 -0
  19. package/dist/components/SettingsView.d.ts +11 -0
  20. package/dist/components/SettingsView.js +34 -0
  21. package/dist/components/TemperatureControl.d.ts +10 -0
  22. package/dist/components/TemperatureControl.js +38 -0
  23. package/dist/components/TemperatureDisplay.d.ts +10 -0
  24. package/dist/components/TemperatureDisplay.js +40 -0
  25. package/dist/hooks/useMug.d.ts +14 -0
  26. package/dist/hooks/useMug.js +112 -0
  27. package/dist/index.d.ts +5 -0
  28. package/dist/index.js +5 -0
  29. package/dist/lib/bluetooth.d.ts +43 -0
  30. package/dist/lib/bluetooth.js +334 -0
  31. package/dist/lib/settings.d.ts +28 -0
  32. package/dist/lib/settings.js +73 -0
  33. package/dist/lib/types.d.ts +57 -0
  34. package/dist/lib/types.js +34 -0
  35. package/dist/lib/utils.d.ts +20 -0
  36. package/dist/lib/utils.js +152 -0
  37. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
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,106 @@
1
+ # Ember Mug CLI
2
+
3
+ [![npm version](https://badge.fury.io/js/ember-mug.svg)](https://www.npmjs.com/package/ember-mug)
4
+ [![CI](https://github.com/singerbj/ember-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/singerbj/ember-cli/actions/workflows/ci.yml)
5
+
6
+ A TypeScript CLI application for controlling Ember mugs, built with [Ink](https://github.com/vadimdemedes/ink) (React for CLIs).
7
+
8
+ ## Installation
9
+
10
+ ### Global Installation (Recommended)
11
+
12
+ ```bash
13
+ npm install -g ember-mug
14
+ ```
15
+
16
+ Then run:
17
+
18
+ ```bash
19
+ ember-mug
20
+ ```
21
+
22
+ ### Run with npx (No Installation)
23
+
24
+ ```bash
25
+ npx ember-mug
26
+ ```
27
+
28
+ ### Local Development
29
+
30
+ ```bash
31
+ git clone https://github.com/singerbj/ember-cli.git
32
+ cd ember-cli
33
+ npm install
34
+ npm run dev
35
+ ```
36
+
37
+ ## Features
38
+
39
+ - **Temperature Control**: View current temperature and adjust target temperature
40
+ - **Temperature Presets**: Quick-select from predefined temperature presets (Latte, Coffee, Tea)
41
+ - **Battery Monitoring**: Real-time battery level with estimated battery life
42
+ - **Time Estimates**:
43
+ - Estimated time to reach target temperature
44
+ - Estimated battery life based on current mug state
45
+ - **LED Color Control**: Customize your mug's LED color with presets or custom RGB values
46
+ - **Temperature Unit Toggle**: Switch between Celsius and Fahrenheit
47
+ - **Persistent Settings**: Your preferences are saved between sessions
48
+
49
+ ## Controls
50
+
51
+ ### When Disconnected
52
+ - `s` - Start scanning for Ember mug
53
+ - `r` - Retry scanning (after error)
54
+ - `q` - Quit
55
+
56
+ ### When Connected
57
+ - `t` - Enter temperature adjustment mode
58
+ - `←/→` or `h/l` - Adjust by ±0.5°
59
+ - `↑/↓` or `j/k` - Adjust by ±1°
60
+ - `t` or `Enter` - Exit temperature mode
61
+ - `1-3` - Select temperature preset
62
+ - `c` - Toggle LED color control
63
+ - `1-8` - Select preset color
64
+ - `c` - Toggle custom color mode
65
+ - `r/g/b` - Select color channel (in custom mode)
66
+ - `←/→` - Adjust selected channel
67
+ - `u` - Toggle temperature unit (°C/°F)
68
+ - `o` - Open settings
69
+ - `q` - Quit
70
+
71
+ ## Requirements
72
+
73
+ - Node.js 18+
74
+ - Bluetooth adapter with BLE support
75
+ - Ember Mug 2 (other models may work but are untested)
76
+
77
+ ### Platform-Specific Notes
78
+
79
+ #### Linux
80
+ You may need to grant Bluetooth permissions:
81
+ ```bash
82
+ sudo setcap cap_net_raw+eip $(eval readlink -f `which node`)
83
+ ```
84
+
85
+ #### macOS
86
+ Grant Bluetooth permissions to your terminal application in System Preferences > Security & Privacy > Privacy > Bluetooth.
87
+
88
+ #### Windows
89
+ Requires Windows 10 build 15063 or later with Bluetooth 4.0+ adapter.
90
+
91
+ ## Technical Details
92
+
93
+ This application uses:
94
+ - [@abandonware/noble](https://github.com/abandonware/noble) for Bluetooth LE communication
95
+ - [Ink](https://github.com/vadimdemedes/ink) for the React-based CLI interface
96
+ - [Conf](https://github.com/sindresorhus/conf) for persistent settings storage
97
+
98
+ The Ember mug Bluetooth protocol was reverse-engineered by [orlopau/ember-mug](https://github.com/orlopau/ember-mug).
99
+
100
+ ## Versioning
101
+
102
+ This project uses [Semantic Versioning](https://semver.org/). Releases are published to npm automatically when a new version tag is pushed to the main branch.
103
+
104
+ ## License
105
+
106
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,13 @@
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
+ // Handle graceful shutdown
6
+ process.on('SIGINT', () => {
7
+ process.exit(0);
8
+ });
9
+ process.on('SIGTERM', () => {
10
+ process.exit(0);
11
+ });
12
+ // Render the app
13
+ render(_jsx(App, {}));
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare function App(): React.ReactElement;
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback } from 'react';
3
+ import { Box, useApp, useInput } from 'ink';
4
+ import { useMug } from '../hooks/useMug.js';
5
+ import { Header } from './Header.js';
6
+ import { TemperatureDisplay } from './TemperatureDisplay.js';
7
+ import { BatteryDisplay } from './BatteryDisplay.js';
8
+ import { TemperatureControl } from './TemperatureControl.js';
9
+ import { Presets } from './Presets.js';
10
+ import { ColorControl } from './ColorControl.js';
11
+ import { ConnectionStatus } from './ConnectionStatus.js';
12
+ import { SettingsView } from './SettingsView.js';
13
+ import { HelpDisplay } from './HelpDisplay.js';
14
+ import { TemperatureUnit } from '../lib/types.js';
15
+ import { getPresets, getTemperatureUnit, setTemperatureUnit as saveTemperatureUnit, setLastTargetTemp, } from '../lib/settings.js';
16
+ export function App() {
17
+ const { exit } = useApp();
18
+ const { state: mugState, isScanning, error, foundMugName, startScanning, setTargetTemp, setTemperatureUnit: setMugTempUnit, setLedColor, } = useMug();
19
+ const [viewMode, setViewMode] = useState('main');
20
+ const [activeControl, setActiveControl] = useState('none');
21
+ const [presets] = useState(getPresets());
22
+ const [selectedPresetIndex, setSelectedPresetIndex] = useState(-1);
23
+ const [localTempUnit, setLocalTempUnit] = useState(getTemperatureUnit());
24
+ // Start scanning on mount
25
+ useEffect(() => {
26
+ startScanning();
27
+ }, [startScanning]);
28
+ // Sync temperature unit with mug when connected
29
+ useEffect(() => {
30
+ if (mugState.connected) {
31
+ setLocalTempUnit(mugState.temperatureUnit);
32
+ }
33
+ }, [mugState.connected, mugState.temperatureUnit]);
34
+ const handleTempChange = useCallback(async (temp) => {
35
+ await setTargetTemp(temp);
36
+ setLastTargetTemp(temp);
37
+ setSelectedPresetIndex(-1);
38
+ }, [setTargetTemp]);
39
+ const handlePresetSelect = useCallback(async (preset) => {
40
+ await setTargetTemp(preset.temperature);
41
+ setLastTargetTemp(preset.temperature);
42
+ const index = presets.findIndex((p) => p.id === preset.id);
43
+ setSelectedPresetIndex(index);
44
+ }, [setTargetTemp, presets]);
45
+ const handleColorChange = useCallback(async (color) => {
46
+ await setLedColor(color);
47
+ }, [setLedColor]);
48
+ const handleTemperatureUnitChange = useCallback(async (unit) => {
49
+ setLocalTempUnit(unit);
50
+ saveTemperatureUnit(unit);
51
+ if (mugState.connected) {
52
+ await setMugTempUnit(unit);
53
+ }
54
+ }, [mugState.connected, setMugTempUnit]);
55
+ useInput((input, key) => {
56
+ // Global controls
57
+ if (input === 'q' && viewMode === 'main' && activeControl === 'none') {
58
+ exit();
59
+ return;
60
+ }
61
+ // Handle escape to go back
62
+ if (key.escape) {
63
+ if (activeControl !== 'none') {
64
+ setActiveControl('none');
65
+ }
66
+ else if (viewMode !== 'main') {
67
+ setViewMode('main');
68
+ }
69
+ return;
70
+ }
71
+ // Only handle these if we're in main view with no active control
72
+ if (viewMode === 'main' && activeControl === 'none') {
73
+ if (input === 's' && !mugState.connected) {
74
+ startScanning();
75
+ return;
76
+ }
77
+ if (input === 'r' && !mugState.connected && error) {
78
+ startScanning();
79
+ return;
80
+ }
81
+ if (mugState.connected) {
82
+ if (input === 't') {
83
+ setActiveControl('temperature');
84
+ return;
85
+ }
86
+ if (input === 'c') {
87
+ setActiveControl('color');
88
+ return;
89
+ }
90
+ if (input === 'o') {
91
+ setViewMode('settings');
92
+ return;
93
+ }
94
+ if (input === 'u') {
95
+ const newUnit = localTempUnit === TemperatureUnit.Celsius
96
+ ? TemperatureUnit.Fahrenheit
97
+ : TemperatureUnit.Celsius;
98
+ handleTemperatureUnitChange(newUnit);
99
+ return;
100
+ }
101
+ }
102
+ }
103
+ // Exit temperature control mode
104
+ if (activeControl === 'temperature' && (key.return || input === 't')) {
105
+ setActiveControl('none');
106
+ return;
107
+ }
108
+ // Exit color control mode
109
+ if (activeControl === 'color' && input === 'c') {
110
+ setActiveControl('none');
111
+ return;
112
+ }
113
+ });
114
+ // Settings view
115
+ if (viewMode === 'settings') {
116
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected }), _jsx(SettingsView, { presets: presets, temperatureUnit: localTempUnit, onTemperatureUnitChange: handleTemperatureUnitChange, onClose: () => setViewMode('main'), isActive: true })] }));
117
+ }
118
+ // Main view
119
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected }), !mugState.connected ? (_jsx(ConnectionStatus, { isScanning: isScanning, isConnected: mugState.connected, foundMugName: foundMugName, error: error, onRetry: startScanning })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState }) })] }), _jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: activeControl === 'temperature' }), _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === 'none' }), activeControl === 'color' && (_jsx(ColorControl, { color: mugState.color, onColorChange: handleColorChange, isActive: true }))] })), _jsx(HelpDisplay, { isConnected: mugState.connected })] }));
120
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { LiquidState } from '../lib/types.js';
3
+ interface BatteryDisplayProps {
4
+ batteryLevel: number;
5
+ isCharging: boolean;
6
+ liquidState: LiquidState;
7
+ }
8
+ export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, }: BatteryDisplayProps): React.ReactElement;
9
+ export {};
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { formatBatteryLevel, getBatteryIcon, estimateBatteryLife, formatDuration, } from '../lib/utils.js';
4
+ export function BatteryDisplay({ batteryLevel, isCharging, liquidState, }) {
5
+ const batteryColor = getBatteryColor(batteryLevel, isCharging);
6
+ const batteryIcon = getBatteryIcon(batteryLevel, isCharging);
7
+ const batteryTimeEstimate = estimateBatteryLife(batteryLevel, isCharging, liquidState);
8
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { bold: true, children: "Battery" }) }), _jsx(Box, { justifyContent: "center", marginY: 1, children: _jsxs(Text, { color: batteryColor, children: [batteryIcon, " ", formatBatteryLevel(batteryLevel), isCharging && _jsx(Text, { color: "yellow", children: " (Charging)" })] }) }), batteryTimeEstimate !== null && (_jsx(Box, { justifyContent: "center", children: _jsx(Text, { dimColor: true, children: isCharging ? (_jsxs(_Fragment, { children: ['Time to full: ', _jsx(Text, { color: "green", children: formatDuration(batteryTimeEstimate) })] })) : (_jsxs(_Fragment, { children: ['Est. battery life: ', _jsx(Text, { color: batteryLevel < 20 ? 'red' : 'yellow', children: formatDuration(batteryTimeEstimate) })] })) }) })), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(BatteryBar, { level: batteryLevel, isCharging: isCharging }) })] }));
9
+ }
10
+ function BatteryBar({ level, isCharging }) {
11
+ const totalSegments = 20;
12
+ const filledSegments = Math.round((level / 100) * totalSegments);
13
+ const emptySegments = totalSegments - filledSegments;
14
+ const color = getBatteryColor(level, isCharging);
15
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "[" }), _jsx(Text, { color: color, children: '█'.repeat(filledSegments) }), _jsx(Text, { dimColor: true, children: '░'.repeat(emptySegments) }), _jsx(Text, { dimColor: true, children: "]" }), isCharging && _jsx(Text, { color: "yellow", children: " \u26A1" })] }));
16
+ }
17
+ function getBatteryColor(level, isCharging) {
18
+ if (isCharging)
19
+ return 'yellow';
20
+ if (level >= 50)
21
+ return 'green';
22
+ if (level >= 25)
23
+ return 'yellow';
24
+ return 'red';
25
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { RGBColor } from '../lib/types.js';
3
+ interface ColorControlProps {
4
+ color: RGBColor;
5
+ onColorChange: (color: RGBColor) => void;
6
+ isActive: boolean;
7
+ }
8
+ export declare function ColorControl({ color, onColorChange, isActive, }: ColorControlProps): React.ReactElement;
9
+ export {};
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { rgbToHex } from '../lib/utils.js';
5
+ const PRESET_COLORS = [
6
+ { name: 'Orange', color: { r: 255, g: 147, b: 41, a: 255 } },
7
+ { name: 'Red', color: { r: 255, g: 0, b: 0, a: 255 } },
8
+ { name: 'Green', color: { r: 0, g: 255, b: 0, a: 255 } },
9
+ { name: 'Blue', color: { r: 0, g: 128, b: 255, a: 255 } },
10
+ { name: 'Purple', color: { r: 128, g: 0, b: 255, a: 255 } },
11
+ { name: 'Pink', color: { r: 255, g: 105, b: 180, a: 255 } },
12
+ { name: 'White', color: { r: 255, g: 255, b: 255, a: 255 } },
13
+ { name: 'Teal', color: { r: 0, g: 255, b: 200, a: 255 } },
14
+ ];
15
+ export function ColorControl({ color, onColorChange, isActive, }) {
16
+ const [selectedChannel, setSelectedChannel] = useState('r');
17
+ const [customMode, setCustomMode] = useState(false);
18
+ useInput((input, key) => {
19
+ if (!isActive)
20
+ return;
21
+ // Number keys for preset colors
22
+ const numKey = parseInt(input, 10);
23
+ if (numKey >= 1 && numKey <= PRESET_COLORS.length) {
24
+ onColorChange(PRESET_COLORS[numKey - 1].color);
25
+ setCustomMode(false);
26
+ return;
27
+ }
28
+ // Toggle custom mode
29
+ if (input === 'c') {
30
+ setCustomMode(!customMode);
31
+ return;
32
+ }
33
+ if (customMode) {
34
+ // Switch channels
35
+ if (input === 'r') {
36
+ setSelectedChannel('r');
37
+ }
38
+ else if (input === 'g') {
39
+ setSelectedChannel('g');
40
+ }
41
+ else if (input === 'b') {
42
+ setSelectedChannel('b');
43
+ }
44
+ // Adjust selected channel
45
+ let delta = 0;
46
+ if (key.leftArrow || input === 'h') {
47
+ delta = -10;
48
+ }
49
+ else if (key.rightArrow || input === 'l') {
50
+ delta = 10;
51
+ }
52
+ else if (key.upArrow || input === 'k') {
53
+ delta = 25;
54
+ }
55
+ else if (key.downArrow || input === 'j') {
56
+ delta = -25;
57
+ }
58
+ if (delta !== 0) {
59
+ const newColor = { ...color };
60
+ newColor[selectedChannel] = Math.max(0, Math.min(255, newColor[selectedChannel] + delta));
61
+ onColorChange(newColor);
62
+ }
63
+ }
64
+ }, { isActive });
65
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { bold: true, children: "LED Color" }) }), _jsx(Box, { justifyContent: "center", marginY: 1, children: _jsxs(Text, { children: ["Current: ", _jsx(Text, { color: rgbToHex(color.r, color.g, color.b), children: "\u25CF" }), ' ', _jsxs(Text, { dimColor: true, children: ["(", rgbToHex(color.r, color.g, color.b), ")"] })] }) }), _jsx(Box, { justifyContent: "center", gap: 1, marginY: 1, children: PRESET_COLORS.map((preset, index) => (_jsxs(Box, { children: [_jsx(Text, { color: rgbToHex(preset.color.r, preset.color.g, preset.color.b), children: "\u25CF" }), _jsx(Text, { dimColor: true, children: index + 1 })] }, preset.name))) }), customMode && (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "Custom Color Mode" }) }), _jsxs(Box, { justifyContent: "center", gap: 2, children: [_jsx(ChannelSlider, { label: "R", value: color.r, isSelected: selectedChannel === 'r', color: "red" }), _jsx(ChannelSlider, { label: "G", value: color.g, isSelected: selectedChannel === 'g', color: "green" }), _jsx(ChannelSlider, { label: "B", value: color.b, isSelected: selectedChannel === 'b', color: "blue" })] })] })), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsxs(Text, { color: "cyan", children: ["1-", PRESET_COLORS.length] }), " for presets |", ' ', _jsx(Text, { color: "cyan", children: "c" }), " for custom mode"] }) })] }));
66
+ }
67
+ function ChannelSlider({ label, value, isSelected, color, }) {
68
+ const width = 10;
69
+ const filled = Math.round((value / 255) * width);
70
+ return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? color : 'gray', children: [label, ": ", '█'.repeat(filled), '░'.repeat(width - filled), " ", value.toString().padStart(3)] }) }));
71
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ interface ConnectionStatusProps {
3
+ isScanning: boolean;
4
+ isConnected: boolean;
5
+ foundMugName: string | null;
6
+ error: string | null;
7
+ onRetry: () => void;
8
+ }
9
+ export declare function ConnectionStatus({ isScanning, isConnected, foundMugName, error, }: ConnectionStatusProps): React.ReactElement;
10
+ export {};
@@ -0,0 +1,9 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ export function ConnectionStatus({ isScanning, isConnected, foundMugName, error, }) {
5
+ if (isConnected) {
6
+ return _jsx(_Fragment, {});
7
+ }
8
+ return (_jsx(Box, { flexDirection: "column", alignItems: "center", marginY: 2, children: error ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsxs(Text, { color: "red", children: ["Error: ", error] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { color: "cyan", children: "r" }), " to retry scanning"] }) })] })) : isScanning ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Scanning for Ember mug..." })] }), foundMugName && (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "green", children: ["Found: ", foundMugName] }), _jsx(Text, { children: " - Connecting..." })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Make sure your Ember mug is powered on and in range" }) })] })) : (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { children: "No Ember mug connected" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { color: "cyan", children: "s" }), " to start scanning"] }) })] })) }));
9
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ interface HeaderProps {
3
+ mugName: string;
4
+ connected: boolean;
5
+ }
6
+ export declare function Header({ mugName, connected }: HeaderProps): React.ReactElement;
7
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function Header({ mugName, connected }) {
4
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: '☕ Ember Mug CLI ' }), connected ? (_jsxs(Text, { color: "green", children: ["[Connected: ", mugName, "]"] })) : (_jsx(Text, { color: "yellow", children: "[Disconnected]" }))] }), _jsx(Text, { dimColor: true, children: '─'.repeat(50) })] }));
5
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ interface HelpDisplayProps {
3
+ isConnected: boolean;
4
+ }
5
+ export declare function HelpDisplay({ isConnected }: HelpDisplayProps): React.ReactElement;
6
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function HelpDisplay({ isConnected }) {
4
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: '─'.repeat(50) }), _jsx(Box, { marginY: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: isConnected ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", children: "t" }), ":temp", ' ', _jsx(Text, { color: "cyan", children: "1-3" }), ":presets", ' ', _jsx(Text, { color: "cyan", children: "c" }), ":color", ' ', _jsx(Text, { color: "cyan", children: "u" }), ":unit", ' ', _jsx(Text, { color: "cyan", children: "o" }), ":settings", ' ', _jsx(Text, { color: "cyan", children: "q" }), ":quit"] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", children: "s" }), ":scan", ' ', _jsx(Text, { color: "cyan", children: "r" }), ":retry", ' ', _jsx(Text, { color: "cyan", children: "q" }), ":quit"] })) }) })] }));
5
+ }
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import { Preset, TemperatureUnit } from '../lib/types.js';
3
+ interface PresetsProps {
4
+ presets: Preset[];
5
+ selectedIndex: number;
6
+ temperatureUnit: TemperatureUnit;
7
+ onSelect: (preset: Preset) => void;
8
+ isActive: boolean;
9
+ }
10
+ export declare function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isActive, }: PresetsProps): React.ReactElement;
11
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { formatTemperature } from '../lib/utils.js';
4
+ export function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isActive, }) {
5
+ useInput((input, key) => {
6
+ if (!isActive)
7
+ return;
8
+ const numKey = parseInt(input, 10);
9
+ if (numKey >= 1 && numKey <= presets.length) {
10
+ onSelect(presets[numKey - 1]);
11
+ }
12
+ }, { isActive });
13
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { bold: true, children: "Presets" }) }), _jsx(Box, { justifyContent: "center", marginY: 1, gap: 2, children: presets.map((preset, index) => (_jsx(PresetButton, { preset: preset, index: index, isSelected: selectedIndex === index, temperatureUnit: temperatureUnit }, preset.id))) }), _jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsxs(Text, { color: "cyan", children: ["1-", presets.length] }), " to select a preset"] }) })] }));
14
+ }
15
+ function PresetButton({ preset, index, isSelected, temperatureUnit, }) {
16
+ const borderColor = isSelected ? 'cyan' : 'gray';
17
+ const textColor = isSelected ? 'cyan' : 'white';
18
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: borderColor, children: isSelected ? '┌───────────┐' : '┌───────────┐' }), _jsxs(Text, { color: borderColor, children: ["\u2502 ", _jsxs(Text, { color: textColor, children: [preset.icon, " ", preset.name.padEnd(6)] }), " \u2502"] }), _jsxs(Text, { color: borderColor, children: ["\u2502 ", _jsx(Text, { color: textColor, children: formatTemperature(preset.temperature, temperatureUnit).padStart(7) }), " \u2502"] }), _jsx(Text, { color: borderColor, children: "\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518" }), _jsxs(Text, { color: "cyan", children: ["[", index + 1, "]"] })] }));
19
+ }
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import { TemperatureUnit, Preset } from '../lib/types.js';
3
+ interface SettingsViewProps {
4
+ presets: Preset[];
5
+ temperatureUnit: TemperatureUnit;
6
+ onTemperatureUnitChange: (unit: TemperatureUnit) => void;
7
+ onClose: () => void;
8
+ isActive: boolean;
9
+ }
10
+ export declare function SettingsView({ presets, temperatureUnit, onTemperatureUnitChange, onClose, isActive, }: SettingsViewProps): React.ReactElement;
11
+ export {};
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { TemperatureUnit } from '../lib/types.js';
5
+ import { formatTemperature } from '../lib/utils.js';
6
+ export function SettingsView({ presets, temperatureUnit, onTemperatureUnitChange, onClose, isActive, }) {
7
+ const [selectedOption, setSelectedOption] = useState('unit');
8
+ useInput((input, key) => {
9
+ if (!isActive)
10
+ return;
11
+ if (key.escape || input === 'q') {
12
+ onClose();
13
+ return;
14
+ }
15
+ if (key.upArrow || input === 'k') {
16
+ setSelectedOption('unit');
17
+ }
18
+ else if (key.downArrow || input === 'j') {
19
+ setSelectedOption('presets');
20
+ }
21
+ if (key.return || input === ' ') {
22
+ if (selectedOption === 'unit') {
23
+ const newUnit = temperatureUnit === TemperatureUnit.Celsius
24
+ ? TemperatureUnit.Fahrenheit
25
+ : TemperatureUnit.Celsius;
26
+ onTemperatureUnitChange(newUnit);
27
+ }
28
+ }
29
+ }, { isActive });
30
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Settings" }) }), _jsx(Text, { dimColor: true, children: '─'.repeat(40) }), _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(SettingRow, { label: "Temperature Unit", value: temperatureUnit === TemperatureUnit.Celsius ? 'Celsius (°C)' : 'Fahrenheit (°F)', isSelected: selectedOption === 'unit', hint: "Press Enter to toggle" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: "Presets:" }) }), presets.map((preset, index) => (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [index + 1, ". ", preset.icon, " ", preset.name, ":", ' ', formatTemperature(preset.temperature, temperatureUnit)] }) }, preset.id)))] }), _jsx(Text, { dimColor: true, children: '─'.repeat(40) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", _jsx(Text, { color: "cyan", children: "Esc" }), " or ", _jsx(Text, { color: "cyan", children: "q" }), " to close settings"] }) })] }));
31
+ }
32
+ function SettingRow({ label, value, isSelected, hint, }) {
33
+ return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? 'cyan' : 'white', children: [isSelected ? '> ' : ' ', label, ": ", _jsx(Text, { bold: true, children: value }), hint && isSelected && _jsxs(Text, { dimColor: true, children: [" (", hint, ")"] })] }) }));
34
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { TemperatureUnit } from '../lib/types.js';
3
+ interface TemperatureControlProps {
4
+ targetTemp: number;
5
+ temperatureUnit: TemperatureUnit;
6
+ onTempChange: (temp: number) => void;
7
+ isActive: boolean;
8
+ }
9
+ export declare function TemperatureControl({ targetTemp, temperatureUnit, onTempChange, isActive, }: TemperatureControlProps): React.ReactElement;
10
+ export {};
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { MIN_TEMP_CELSIUS, MAX_TEMP_CELSIUS } from '../lib/types.js';
4
+ import { formatTemperature, clampTemperature } from '../lib/utils.js';
5
+ export function TemperatureControl({ targetTemp, temperatureUnit, onTempChange, isActive, }) {
6
+ useInput((input, key) => {
7
+ if (!isActive)
8
+ return;
9
+ let delta = 0;
10
+ if (key.leftArrow || input === 'h' || input === '-') {
11
+ delta = -0.5;
12
+ }
13
+ else if (key.rightArrow || input === 'l' || input === '+' || input === '=') {
14
+ delta = 0.5;
15
+ }
16
+ else if (key.upArrow || input === 'k') {
17
+ delta = 1;
18
+ }
19
+ else if (key.downArrow || input === 'j') {
20
+ delta = -1;
21
+ }
22
+ if (delta !== 0) {
23
+ const newTemp = clampTemperature(targetTemp + delta);
24
+ onTempChange(newTemp);
25
+ }
26
+ }, { isActive });
27
+ const minTempDisplay = formatTemperature(MIN_TEMP_CELSIUS, temperatureUnit);
28
+ const maxTempDisplay = formatTemperature(MAX_TEMP_CELSIUS, temperatureUnit);
29
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { bold: true, children: "Adjust Temperature" }) }), _jsxs(Box, { justifyContent: "center", marginY: 1, children: [_jsx(Text, { dimColor: true, children: minTempDisplay }), _jsx(Text, { children: " " }), _jsx(TemperatureSlider, { value: targetTemp, min: MIN_TEMP_CELSIUS, max: MAX_TEMP_CELSIUS, isActive: isActive }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: maxTempDisplay })] }), _jsx(Box, { justifyContent: "center", children: _jsx(Text, { dimColor: true, children: isActive ? (_jsxs(Text, { children: ["Use ", _jsx(Text, { color: "cyan", children: "\u2190/\u2192" }), " or ", _jsx(Text, { color: "cyan", children: "h/l" }), " (\u00B10.5\u00B0) |", ' ', _jsx(Text, { color: "cyan", children: "\u2191/\u2193" }), " or ", _jsx(Text, { color: "cyan", children: "j/k" }), " (\u00B11\u00B0)"] })) : (_jsxs(Text, { children: ["Press ", _jsx(Text, { color: "cyan", children: "t" }), " to adjust temperature"] })) }) })] }));
30
+ }
31
+ function TemperatureSlider({ value, min, max, isActive, }) {
32
+ const totalWidth = 20;
33
+ const normalizedValue = (value - min) / (max - min);
34
+ const position = Math.round(normalizedValue * (totalWidth - 1));
35
+ const sliderChars = Array(totalWidth).fill('─');
36
+ sliderChars[position] = '●';
37
+ return (_jsx(Text, { color: isActive ? 'cyan' : 'white', children: sliderChars.join('') }));
38
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { LiquidState, TemperatureUnit } from '../lib/types.js';
3
+ interface TemperatureDisplayProps {
4
+ currentTemp: number;
5
+ targetTemp: number;
6
+ liquidState: LiquidState;
7
+ temperatureUnit: TemperatureUnit;
8
+ }
9
+ export declare function TemperatureDisplay({ currentTemp, targetTemp, liquidState, temperatureUnit, }: TemperatureDisplayProps): React.ReactElement;
10
+ export {};
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { LiquidState } from '../lib/types.js';
4
+ import { formatTemperature, getLiquidStateText, getLiquidStateIcon, estimateTimeToTargetTemp, formatDuration, } from '../lib/utils.js';
5
+ export function TemperatureDisplay({ currentTemp, targetTemp, liquidState, temperatureUnit, }) {
6
+ const isEmpty = liquidState === LiquidState.Empty;
7
+ const isAtTarget = Math.abs(currentTemp - targetTemp) < 0.5 && !isEmpty;
8
+ const tempDiff = currentTemp - targetTemp;
9
+ let tempColor;
10
+ if (isEmpty) {
11
+ tempColor = 'gray';
12
+ }
13
+ else if (Math.abs(tempDiff) < 1) {
14
+ tempColor = 'green';
15
+ }
16
+ else if (tempDiff > 0) {
17
+ tempColor = 'red';
18
+ }
19
+ else {
20
+ tempColor = 'blue';
21
+ }
22
+ const timeToTarget = estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState);
23
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { bold: true, children: "Temperature" }) }), _jsxs(Box, { marginY: 1, justifyContent: "center", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { dimColor: true, children: "Current" }), _jsx(Text, { bold: true, color: tempColor, children: isEmpty ? '---' : formatTemperature(currentTemp, temperatureUnit) })] }), _jsx(Box, { marginX: 3, children: _jsx(Text, { dimColor: true, children: ' → ' }) }), _jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { dimColor: true, children: "Target" }), _jsx(Text, { bold: true, color: "cyan", children: formatTemperature(targetTemp, temperatureUnit) })] })] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: getStateColor(liquidState), children: [getLiquidStateIcon(liquidState), " ", getLiquidStateText(liquidState)] }), isAtTarget && _jsx(Text, { color: "green", children: " - Perfect!" })] }) }), timeToTarget !== null && timeToTarget > 0 && (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ['Est. time to target: ', _jsx(Text, { color: "yellow", children: formatDuration(timeToTarget) })] }) })), timeToTarget === 0 && !isEmpty && (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: "green", children: "At target temperature!" }) }))] }));
24
+ }
25
+ function getStateColor(state) {
26
+ switch (state) {
27
+ case LiquidState.Empty:
28
+ return 'gray';
29
+ case LiquidState.Filling:
30
+ return 'cyan';
31
+ case LiquidState.Cooling:
32
+ return 'blue';
33
+ case LiquidState.Heating:
34
+ return 'red';
35
+ case LiquidState.StableTemperature:
36
+ return 'green';
37
+ default:
38
+ return 'white';
39
+ }
40
+ }