ember-mug 0.1.4 → 0.1.6
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/README.md +22 -15
- package/dist/cli.js +0 -0
- package/dist/components/App.js +4 -4
- package/dist/components/BatteryDisplay.d.ts +2 -1
- package/dist/components/BatteryDisplay.js +2 -2
- package/dist/components/HelpDisplay.d.ts +3 -1
- package/dist/components/HelpDisplay.js +2 -2
- package/dist/components/SettingsView.js +16 -8
- package/dist/components/TemperatureDisplay.d.ts +2 -1
- package/dist/components/TemperatureDisplay.js +2 -2
- package/dist/hooks/useMug.d.ts +2 -0
- package/dist/hooks/useMug.js +47 -0
- package/dist/lib/bluetooth.js +6 -1
- package/dist/lib/utils.d.ts +6 -2
- package/dist/lib/utils.js +44 -5
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -39,35 +39,37 @@ npm run dev
|
|
|
39
39
|
- **Temperature Control**: View current temperature and adjust target temperature
|
|
40
40
|
- **Temperature Presets**: Quick-select from predefined temperature presets (Latte, Coffee, Tea)
|
|
41
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
|
|
45
|
-
- **LED Color Control**: Customize your mug's LED color
|
|
42
|
+
- **Dynamic Time Estimates**:
|
|
43
|
+
- Estimated time to reach target temperature based on real-time heating/cooling rates
|
|
44
|
+
- Estimated battery life based on actual discharge/charge rates
|
|
45
|
+
- **LED Color Control**: Customize your mug's LED color via the settings panel
|
|
46
46
|
- **Temperature Unit Toggle**: Switch between Celsius and Fahrenheit
|
|
47
|
-
- **Persistent Settings**: Your preferences are saved between sessions
|
|
47
|
+
- **Persistent Settings**: Your preferences and custom presets are saved between sessions
|
|
48
|
+
- **Responsive Layout**: Adapts to narrow or wide terminals automatically
|
|
48
49
|
|
|
49
50
|
## Controls
|
|
50
51
|
|
|
51
52
|
### When Disconnected
|
|
53
|
+
|
|
52
54
|
- `s` - Start scanning for Ember mug
|
|
53
55
|
- `r` - Retry scanning (after error)
|
|
54
56
|
- `q` - Quit
|
|
55
57
|
|
|
56
58
|
### When Connected
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
- `↑/↓` or `j/k` - Adjust by ±1°
|
|
60
|
-
- `t` or `Enter` - Exit temperature mode
|
|
59
|
+
|
|
60
|
+
- `←/→` or `h/l` - Adjust temperature by ±0.5°
|
|
61
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
62
|
- `u` - Toggle temperature unit (°C/°F)
|
|
68
|
-
- `o` - Open settings
|
|
63
|
+
- `o` - Open settings (LED color, unit, preset editing)
|
|
69
64
|
- `q` - Quit
|
|
70
65
|
|
|
66
|
+
### In Settings
|
|
67
|
+
|
|
68
|
+
- `↑/↓` or `j/k` - Navigate settings
|
|
69
|
+
- `Enter/Space` - Toggle unit
|
|
70
|
+
- `←/→` or `h/l` - Change LED color or edit preset temperature
|
|
71
|
+
- `Esc` or `q` - Close settings
|
|
72
|
+
|
|
71
73
|
## Requirements
|
|
72
74
|
|
|
73
75
|
- Node.js 18+
|
|
@@ -77,20 +79,25 @@ npm run dev
|
|
|
77
79
|
### Platform-Specific Notes
|
|
78
80
|
|
|
79
81
|
#### Linux
|
|
82
|
+
|
|
80
83
|
You may need to grant Bluetooth permissions:
|
|
84
|
+
|
|
81
85
|
```bash
|
|
82
86
|
sudo setcap cap_net_raw+eip $(eval readlink -f `which node`)
|
|
83
87
|
```
|
|
84
88
|
|
|
85
89
|
#### macOS
|
|
90
|
+
|
|
86
91
|
Grant Bluetooth permissions to your terminal application in System Preferences > Security & Privacy > Privacy > Bluetooth.
|
|
87
92
|
|
|
88
93
|
#### Windows
|
|
94
|
+
|
|
89
95
|
Requires Windows 10 build 15063 or later with Bluetooth 4.0+ adapter.
|
|
90
96
|
|
|
91
97
|
## Technical Details
|
|
92
98
|
|
|
93
99
|
This application uses:
|
|
100
|
+
|
|
94
101
|
- [@abandonware/noble](https://github.com/abandonware/noble) for Bluetooth LE communication
|
|
95
102
|
- [Ink](https://github.com/vadimdemedes/ink) for the React-based CLI interface
|
|
96
103
|
- [Conf](https://github.com/sindresorhus/conf) for persistent settings storage
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/components/App.js
CHANGED
|
@@ -24,7 +24,7 @@ export function App() {
|
|
|
24
24
|
const panelWidth = isNarrowTerminal
|
|
25
25
|
? terminalWidth - 4 // Full width minus margins for stacked layout
|
|
26
26
|
: Math.floor((terminalWidth - 6) / 2); // Half width minus gaps for 2x2 grid
|
|
27
|
-
const { state: mugState, isScanning, error, foundMugName, startScanning, setTargetTemp, setTemperatureUnit: setMugTempUnit, setLedColor, } = useMug();
|
|
27
|
+
const { state: mugState, isScanning, error, foundMugName, startScanning, setTargetTemp, setTemperatureUnit: setMugTempUnit, setLedColor, tempRate, batteryRate, } = useMug();
|
|
28
28
|
// Get theme based on mug state
|
|
29
29
|
const themeKey = getThemeForState(mugState.liquidState, mugState.connected);
|
|
30
30
|
const theme = getTerminalTheme(themeKey);
|
|
@@ -94,7 +94,7 @@ export function App() {
|
|
|
94
94
|
}
|
|
95
95
|
// Only handle these if we're in main view with no active control
|
|
96
96
|
if (viewMode === "main" && activeControl === "none") {
|
|
97
|
-
if (input === "s" && !mugState.connected) {
|
|
97
|
+
if (input === "s" && !mugState.connected && !isScanning) {
|
|
98
98
|
startScanning();
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
@@ -125,7 +125,7 @@ export function App() {
|
|
|
125
125
|
// Main view
|
|
126
126
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected, theme: theme, ledColor: mugState.color }), !mugState.connected ? (_jsx(ConnectionStatus, { isScanning: isScanning, isConnected: mugState.connected, foundMugName: foundMugName, error: error, onRetry: startScanning, width: panelWidth, theme: theme })) : (_jsx(Box, { flexDirection: "column", children: isNarrowTerminal ? (
|
|
127
127
|
/* Narrow terminal: stack all panels vertically */
|
|
128
|
-
_jsxs(_Fragment, { children: [_jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit, width: panelWidth, theme: theme }), _jsx(Box, { marginTop: 1, children: _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, theme: theme }) }), _jsx(Box, { marginTop: 1, children: _jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: true, width: panelWidth, theme: theme }) }), _jsx(Box, { marginTop: 1, children: _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === "none", width: panelWidth, theme: theme }) })] })) : (
|
|
128
|
+
_jsxs(_Fragment, { children: [_jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit, width: panelWidth, theme: theme, tempRate: tempRate }), _jsx(Box, { marginTop: 1, children: _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, theme: theme, batteryRate: batteryRate }) }), _jsx(Box, { marginTop: 1, children: _jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: true, width: panelWidth, theme: theme }) }), _jsx(Box, { marginTop: 1, children: _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === "none", width: panelWidth, theme: theme }) })] })) : (
|
|
129
129
|
/* Wide terminal: 2x2 grid layout */
|
|
130
|
-
_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "center", gap: 2, children: [_jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit, width: panelWidth, height: 8, theme: theme }), _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, height: 8, theme: theme })] }), _jsxs(Box, { justifyContent: "center", gap: 2, marginTop: 1, children: [_jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: true, width: panelWidth, height: 9, theme: theme }), _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === "none", width: panelWidth, height: 9, theme: theme })] })] })) })), _jsx(HelpDisplay, { isConnected: mugState.connected, theme: theme })] }));
|
|
130
|
+
_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "center", gap: 2, children: [_jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit, width: panelWidth, height: 8, theme: theme, tempRate: tempRate }), _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, height: 8, theme: theme, batteryRate: batteryRate })] }), _jsxs(Box, { justifyContent: "center", gap: 2, marginTop: 1, children: [_jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: true, width: panelWidth, height: 9, theme: theme }), _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === "none", width: panelWidth, height: 9, theme: theme })] })] })) })), _jsx(HelpDisplay, { isConnected: mugState.connected, isScanning: isScanning, error: error, theme: theme })] }));
|
|
131
131
|
}
|
|
@@ -9,6 +9,7 @@ interface BatteryDisplayProps {
|
|
|
9
9
|
width?: number;
|
|
10
10
|
height?: number;
|
|
11
11
|
theme: TerminalTheme;
|
|
12
|
+
batteryRate?: number;
|
|
12
13
|
}
|
|
13
|
-
export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, }: BatteryDisplayProps): React.ReactElement;
|
|
14
|
+
export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, batteryRate, }: BatteryDisplayProps): React.ReactElement;
|
|
14
15
|
export {};
|
|
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { formatBatteryLevel, estimateBatteryLife, formatDuration, } from "../lib/utils.js";
|
|
4
4
|
import { Panel } from "./Panel.js";
|
|
5
|
-
export function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, }) {
|
|
6
|
-
const batteryTimeEstimate = estimateBatteryLife(batteryLevel, isCharging, liquidState);
|
|
5
|
+
export function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, batteryRate, }) {
|
|
6
|
+
const batteryTimeEstimate = estimateBatteryLife(batteryLevel, isCharging, liquidState, batteryRate);
|
|
7
7
|
return (_jsxs(Panel, { title: "Battery", titleColor: theme.primary, borderColor: theme.border, width: width, height: height, children: [_jsxs(Box, { justifyContent: "center", marginY: 1, children: [_jsx(Text, { color: theme.primary, bold: true, children: formatBatteryLevel(batteryLevel) }), isCharging && (_jsxs(Text, { color: theme.primary, bold: true, children: [" ", "~"] }))] }), _jsx(Box, { justifyContent: "center", children: _jsx(BatteryBar, { level: batteryLevel, isCharging: isCharging, theme: theme }) }), batteryTimeEstimate !== null && (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: theme.dimText, children: isCharging ? (_jsxs(_Fragment, { children: ["[~] ", _jsx(Text, { color: theme.primary, children: formatDuration(batteryTimeEstimate) }), _jsx(Text, { color: theme.dimText, children: " to full" })] })) : (_jsxs(_Fragment, { children: ["[~] ", _jsx(Text, { color: theme.primary, children: formatDuration(batteryTimeEstimate) }), _jsx(Text, { color: theme.dimText, children: " remaining" })] })) }) }))] }));
|
|
8
8
|
}
|
|
9
9
|
function BatteryBar({ level, isCharging, theme, }) {
|
|
@@ -3,7 +3,9 @@ import { TERMINAL_COLORS } from "../lib/theme.js";
|
|
|
3
3
|
type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
|
|
4
4
|
interface HelpDisplayProps {
|
|
5
5
|
isConnected: boolean;
|
|
6
|
+
isScanning?: boolean;
|
|
7
|
+
error?: string | null;
|
|
6
8
|
theme: TerminalTheme;
|
|
7
9
|
}
|
|
8
|
-
export declare function HelpDisplay({ isConnected, theme, }: HelpDisplayProps): React.ReactElement;
|
|
10
|
+
export declare function HelpDisplay({ isConnected, isScanning, error, theme, }: HelpDisplayProps): React.ReactElement;
|
|
9
11
|
export {};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useStdout } from "ink";
|
|
3
|
-
export function HelpDisplay({ isConnected, theme, }) {
|
|
3
|
+
export function HelpDisplay({ isConnected, isScanning, error, theme, }) {
|
|
4
4
|
const { stdout } = useStdout();
|
|
5
5
|
const width = stdout?.columns || 80;
|
|
6
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.border, children: "─".repeat(width) }), _jsx(Box, { paddingY: 1, justifyContent: "center", gap: 2, children: isConnected ? (_jsxs(_Fragment, { children: [_jsx(HelpKey, { keyChar: "t", label: "temp", color: theme.primary }), _jsx(HelpKey, { keyChar: "1-3", label: "presets", color: theme.primary }), _jsx(HelpKey, { keyChar: "u", label: "unit", color: theme.primary }), _jsx(HelpKey, { keyChar: "o", label: "settings", color: theme.primary }), _jsx(HelpKey, { keyChar: "q", label: "quit", color: theme.primary })] })) : (_jsxs(_Fragment, { children: [_jsx(HelpKey, { keyChar: "s", label: "scan", color: theme.primary }), _jsx(HelpKey, { keyChar: "r", label: "retry", color: theme.primary }), _jsx(HelpKey, { keyChar: "q", label: "quit", color: theme.primary })] })) })] }));
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.border, children: "─".repeat(width) }), _jsx(Box, { paddingY: 1, justifyContent: "center", gap: 2, children: isConnected ? (_jsxs(_Fragment, { children: [_jsx(HelpKey, { keyChar: "t", label: "temp", color: theme.primary }), _jsx(HelpKey, { keyChar: "1-3", label: "presets", color: theme.primary }), _jsx(HelpKey, { keyChar: "u", label: "unit", color: theme.primary }), _jsx(HelpKey, { keyChar: "o", label: "settings", color: theme.primary }), _jsx(HelpKey, { keyChar: "q", label: "quit", color: theme.primary })] })) : (_jsxs(_Fragment, { children: [!isScanning && !error && (_jsx(HelpKey, { keyChar: "s", label: "scan", color: theme.primary })), error && (_jsx(HelpKey, { keyChar: "r", label: "retry", color: theme.primary })), _jsx(HelpKey, { keyChar: "q", label: "quit", color: theme.primary })] })) })] }));
|
|
7
7
|
}
|
|
8
8
|
function HelpKey({ keyChar, label, color }) {
|
|
9
9
|
return (_jsxs(Text, { children: [_jsxs(Text, { color: color, bold: true, children: ["[", keyChar, "]"] }), _jsxs(Text, { dimColor: true, children: [" ", label] })] }));
|
|
@@ -47,9 +47,11 @@ export function SettingsView({ presets, temperatureUnit, ledColor, onTemperature
|
|
|
47
47
|
else if (selectedIndex === 1) {
|
|
48
48
|
// LED Color
|
|
49
49
|
if (key.leftArrow || input === "h" || key.rightArrow || input === "l") {
|
|
50
|
-
const currentColorIndex =
|
|
51
|
-
p.color.
|
|
52
|
-
|
|
50
|
+
const currentColorIndex = ledColor
|
|
51
|
+
? PRESET_COLORS.findIndex((p) => p.color.r === ledColor.r &&
|
|
52
|
+
p.color.g === ledColor.g &&
|
|
53
|
+
p.color.b === ledColor.b)
|
|
54
|
+
: -1;
|
|
53
55
|
let nextIndex = currentColorIndex;
|
|
54
56
|
if (key.leftArrow || input === "h") {
|
|
55
57
|
nextIndex = currentColorIndex - 1;
|
|
@@ -83,15 +85,21 @@ export function SettingsView({ presets, temperatureUnit, ledColor, onTemperature
|
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
}, { isActive });
|
|
86
|
-
const currentColorPreset =
|
|
87
|
-
p.color.
|
|
88
|
-
|
|
88
|
+
const currentColorPreset = ledColor
|
|
89
|
+
? PRESET_COLORS.find((p) => p.color.r === ledColor.r &&
|
|
90
|
+
p.color.g === ledColor.g &&
|
|
91
|
+
p.color.b === ledColor.b)
|
|
92
|
+
: undefined;
|
|
89
93
|
const colorDisplayValue = currentColorPreset
|
|
90
94
|
? currentColorPreset.name
|
|
91
|
-
:
|
|
95
|
+
: ledColor
|
|
96
|
+
? rgbToHex(ledColor.r, ledColor.g, ledColor.b)
|
|
97
|
+
: "Unknown";
|
|
92
98
|
return (_jsx(Box, { justifyContent: "center", marginY: 1, children: _jsxs(Panel, { title: "[=] Settings", titleColor: theme.primary, borderColor: theme.border, width: panelWidth, height: 16, children: [_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(SettingRow, { label: "Temperature Unit", value: temperatureUnit === TemperatureUnit.Celsius
|
|
93
99
|
? "Celsius (°C)"
|
|
94
|
-
: "Fahrenheit (°F)", isSelected: selectedIndex === 0, hint: "Enter/Space to toggle", theme: theme }), _jsx(SettingRow, { label: "LED Color", value: colorDisplayValue, valueColor:
|
|
100
|
+
: "Fahrenheit (°F)", isSelected: selectedIndex === 0, hint: "Enter/Space to toggle", theme: theme }), _jsx(SettingRow, { label: "LED Color", value: colorDisplayValue, valueColor: ledColor
|
|
101
|
+
? rgbToHex(ledColor.r, ledColor.g, ledColor.b)
|
|
102
|
+
: undefined, isSelected: selectedIndex === 1, hint: "Left/Right to change", theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.primary, bold: true, children: "Presets:" }), presets.map((preset, index) => (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.dimText, children: [_jsxs(Text, { color: selectedIndex === index + 2
|
|
95
103
|
? theme.primary
|
|
96
104
|
: theme.dimText, children: [selectedIndex === index + 2 ? "> " : " ", index + 1, "."] }), " ", preset.name, ":", " ", _jsx(Text, { color: selectedIndex === index + 2 ? theme.text : theme.dimText, bold: selectedIndex === index + 2, children: formatTemperature(preset.temperature, temperatureUnit) }), selectedIndex === index + 2 && (_jsx(Text, { dimColor: true, children: " (Left/Right to edit)" }))] }) }, preset.id)))] })] }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsxs(Text, { color: theme.dimText, children: [_jsx(Text, { color: theme.primary, bold: true, children: "[Esc]" }), " ", "or", " ", _jsx(Text, { color: theme.primary, bold: true, children: "[q]" }), " ", "to close"] }) }), _jsx(Box, { justifyContent: "center", children: _jsx(Text, { color: theme.dimText, dimColor: true, children: "(Use Arrow Keys to navigate and edit)" }) })] }) }));
|
|
97
105
|
}
|
|
@@ -10,6 +10,7 @@ interface TemperatureDisplayProps {
|
|
|
10
10
|
width?: number;
|
|
11
11
|
height?: number;
|
|
12
12
|
theme: TerminalTheme;
|
|
13
|
+
tempRate?: number;
|
|
13
14
|
}
|
|
14
|
-
export declare function TemperatureDisplay({ currentTemp, targetTemp, liquidState, temperatureUnit, width, height, theme, }: TemperatureDisplayProps): React.ReactElement;
|
|
15
|
+
export declare function TemperatureDisplay({ currentTemp, targetTemp, liquidState, temperatureUnit, width, height, theme, tempRate, }: TemperatureDisplayProps): React.ReactElement;
|
|
15
16
|
export {};
|
|
@@ -3,10 +3,10 @@ import { Box, Text } from "ink";
|
|
|
3
3
|
import { LiquidState } from "../lib/types.js";
|
|
4
4
|
import { formatTemperature, getLiquidStateText, estimateTimeToTargetTemp, formatDuration, } from "../lib/utils.js";
|
|
5
5
|
import { Panel } from "./Panel.js";
|
|
6
|
-
export function TemperatureDisplay({ currentTemp, targetTemp, liquidState, temperatureUnit, width, height, theme, }) {
|
|
6
|
+
export function TemperatureDisplay({ currentTemp, targetTemp, liquidState, temperatureUnit, width, height, theme, tempRate, }) {
|
|
7
7
|
const isEmpty = liquidState === LiquidState.Empty;
|
|
8
8
|
const isAtTarget = Math.abs(currentTemp - targetTemp) < 0.5 && !isEmpty;
|
|
9
|
-
const timeToTarget = estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState);
|
|
9
|
+
const timeToTarget = estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState, tempRate);
|
|
10
10
|
return (_jsxs(Panel, { title: "Temperature", titleColor: theme.primary, borderColor: theme.border, width: width, height: height, children: [_jsxs(Box, { marginY: 1, justifyContent: "center", width: "100%", children: [_jsxs(Box, { flexDirection: "column", alignItems: "center", minWidth: 16, flexShrink: 0, children: [_jsx(Box, { children: _jsx(Text, { color: theme.dimText, children: "Current" }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: theme.primary, children: isEmpty
|
|
11
11
|
? "---"
|
|
12
12
|
: formatTemperature(currentTemp, temperatureUnit) }) })] }), _jsx(Box, { marginX: 2, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: theme.primary, bold: true, children: "-->" }) }), _jsxs(Box, { flexDirection: "column", alignItems: "center", minWidth: 16, flexShrink: 0, children: [_jsx(Box, { children: _jsx(Text, { color: theme.dimText, children: "Target" }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: theme.text, children: formatTemperature(targetTemp, temperatureUnit) }) })] })] }), _jsx(Box, { justifyContent: "center", children: _jsxs(Text, { children: [_jsx(Text, { color: theme.primary, bold: true, children: getLiquidStateText(liquidState) }), isAtTarget && (_jsxs(Text, { color: theme.primary, bold: true, children: [" ", "*"] })), timeToTarget !== null && timeToTarget > 0 && (_jsxs(Text, { color: theme.dimText, children: [" • [~] ", _jsx(Text, { color: theme.primary, children: formatDuration(timeToTarget) }), _jsx(Text, { color: theme.dimText, children: " to target" })] }))] }) })] }));
|
package/dist/hooks/useMug.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ interface UseMugReturn {
|
|
|
9
9
|
setTemperatureUnit: (unit: TemperatureUnit) => Promise<void>;
|
|
10
10
|
setLedColor: (color: RGBColor) => Promise<void>;
|
|
11
11
|
disconnect: () => Promise<void>;
|
|
12
|
+
tempRate: number;
|
|
13
|
+
batteryRate: number;
|
|
12
14
|
}
|
|
13
15
|
export declare function useMug(): UseMugReturn;
|
|
14
16
|
export {};
|
package/dist/hooks/useMug.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { getBluetoothManager } from "../lib/bluetooth.js";
|
|
3
3
|
import { LiquidState, TemperatureUnit, } from "../lib/types.js";
|
|
4
|
+
import { calculateRate } from "../lib/utils.js";
|
|
4
5
|
const initialState = {
|
|
5
6
|
connected: false,
|
|
6
7
|
batteryLevel: 0,
|
|
@@ -17,6 +18,48 @@ export function useMug() {
|
|
|
17
18
|
const [isScanning, setIsScanning] = useState(true);
|
|
18
19
|
const [error, setError] = useState(null);
|
|
19
20
|
const [foundMugName, setFoundMugName] = useState(null);
|
|
21
|
+
const [tempHistory, setTempHistory] = useState([]);
|
|
22
|
+
const [batteryHistory, setBatteryHistory] = useState([]);
|
|
23
|
+
// Update temp history
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (state.connected && state.liquidState !== LiquidState.Empty) {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
setTempHistory((prev) => {
|
|
28
|
+
const last = prev[prev.length - 1];
|
|
29
|
+
if (last &&
|
|
30
|
+
last.value === state.currentTemp &&
|
|
31
|
+
now - last.time < 10000) {
|
|
32
|
+
return prev;
|
|
33
|
+
}
|
|
34
|
+
const next = [...prev, { value: state.currentTemp, time: now }];
|
|
35
|
+
return next.filter((item) => now - item.time < 5 * 60 * 1000);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else if (!state.connected || state.liquidState === LiquidState.Empty) {
|
|
39
|
+
if (tempHistory.length > 0)
|
|
40
|
+
setTempHistory([]);
|
|
41
|
+
}
|
|
42
|
+
}, [state.currentTemp, state.connected, state.liquidState]);
|
|
43
|
+
// Update battery history
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (state.connected) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
setBatteryHistory((prev) => {
|
|
48
|
+
const last = prev[prev.length - 1];
|
|
49
|
+
if (last &&
|
|
50
|
+
last.value === state.batteryLevel &&
|
|
51
|
+
now - last.time < 30000) {
|
|
52
|
+
return prev;
|
|
53
|
+
}
|
|
54
|
+
const next = [...prev, { value: state.batteryLevel, time: now }];
|
|
55
|
+
return next.filter((item) => now - item.time < 10 * 60 * 1000);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else if (!state.connected) {
|
|
59
|
+
if (batteryHistory.length > 0)
|
|
60
|
+
setBatteryHistory([]);
|
|
61
|
+
}
|
|
62
|
+
}, [state.batteryLevel, state.connected]);
|
|
20
63
|
useEffect(() => {
|
|
21
64
|
const manager = getBluetoothManager();
|
|
22
65
|
const handleStateChange = (newState) => {
|
|
@@ -98,6 +141,8 @@ export function useMug() {
|
|
|
98
141
|
setError(err instanceof Error ? err.message : "Failed to disconnect");
|
|
99
142
|
}
|
|
100
143
|
}, []);
|
|
144
|
+
const tempRate = calculateRate(tempHistory);
|
|
145
|
+
const batteryRate = calculateRate(batteryHistory, 5 * 60 * 1000);
|
|
101
146
|
return {
|
|
102
147
|
state,
|
|
103
148
|
isScanning,
|
|
@@ -108,5 +153,7 @@ export function useMug() {
|
|
|
108
153
|
setTemperatureUnit,
|
|
109
154
|
setLedColor,
|
|
110
155
|
disconnect,
|
|
156
|
+
tempRate,
|
|
157
|
+
batteryRate,
|
|
111
158
|
};
|
|
112
159
|
}
|
package/dist/lib/bluetooth.js
CHANGED
|
@@ -38,7 +38,12 @@ export class BluetoothManager extends EventEmitter {
|
|
|
38
38
|
this.emit('mugFound', name);
|
|
39
39
|
await this.stopScanning();
|
|
40
40
|
this.peripheral = peripheral;
|
|
41
|
-
|
|
41
|
+
try {
|
|
42
|
+
await this.connect();
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
46
|
+
}
|
|
42
47
|
}
|
|
43
48
|
});
|
|
44
49
|
}
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -7,8 +7,12 @@ export declare function getBatteryIcon(level: number, isCharging: boolean): stri
|
|
|
7
7
|
export declare function getLiquidStateText(state: LiquidState): string;
|
|
8
8
|
export declare function clampTemperature(temp: number): number;
|
|
9
9
|
export declare function formatDuration(minutes: number): string;
|
|
10
|
-
export declare function estimateTimeToTargetTemp(currentTemp: number, targetTemp: number, liquidState: LiquidState): number | null;
|
|
11
|
-
export declare function estimateBatteryLife(batteryLevel: number, isCharging: boolean, liquidState: LiquidState): number | null;
|
|
10
|
+
export declare function estimateTimeToTargetTemp(currentTemp: number, targetTemp: number, liquidState: LiquidState, dynamicRate?: number): number | null;
|
|
11
|
+
export declare function estimateBatteryLife(batteryLevel: number, isCharging: boolean, liquidState: LiquidState, dynamicRate?: number): number | null;
|
|
12
|
+
export declare function calculateRate(history: {
|
|
13
|
+
value: number;
|
|
14
|
+
time: number;
|
|
15
|
+
}[], windowMs?: number): number;
|
|
12
16
|
export declare function getTemperatureColor(currentTemp: number, targetTemp: number): string;
|
|
13
17
|
export declare function interpolateColor(value: number, minColor: [number, number, number], maxColor: [number, number, number]): [number, number, number];
|
|
14
18
|
export declare function rgbToHex(r: number, g: number, b: number): string;
|
package/dist/lib/utils.js
CHANGED
|
@@ -62,7 +62,7 @@ export function formatDuration(minutes) {
|
|
|
62
62
|
}
|
|
63
63
|
return `${hours}h ${mins}m`;
|
|
64
64
|
}
|
|
65
|
-
export function estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState) {
|
|
65
|
+
export function estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState, dynamicRate) {
|
|
66
66
|
if (liquidState === LiquidState.Empty) {
|
|
67
67
|
return null;
|
|
68
68
|
}
|
|
@@ -70,15 +70,28 @@ export function estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState) {
|
|
|
70
70
|
if (tempDiff < 0.5) {
|
|
71
71
|
return 0; // Already at target
|
|
72
72
|
}
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
// Use dynamic rate if provided and valid (greater than a small threshold)
|
|
74
|
+
if (dynamicRate && Math.abs(dynamicRate) > 0.05) {
|
|
75
|
+
// Ensure the rate is in the right direction
|
|
76
|
+
const isHeating = currentTemp < targetTemp;
|
|
77
|
+
const isRateHeating = dynamicRate > 0;
|
|
78
|
+
if (isHeating === isRateHeating) {
|
|
79
|
+
return tempDiff / Math.abs(dynamicRate);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Fallback to typical Ember mug heating/cooling rates
|
|
76
83
|
const isHeating = currentTemp < targetTemp;
|
|
77
84
|
const ratePerMinute = isHeating ? 1.0 : 0.5;
|
|
78
85
|
return tempDiff / ratePerMinute;
|
|
79
86
|
}
|
|
80
|
-
export function estimateBatteryLife(batteryLevel, isCharging, liquidState) {
|
|
87
|
+
export function estimateBatteryLife(batteryLevel, isCharging, liquidState, dynamicRate) {
|
|
81
88
|
if (isCharging) {
|
|
89
|
+
if (dynamicRate && dynamicRate > 0.1) {
|
|
90
|
+
const remaining = 100 - batteryLevel;
|
|
91
|
+
if (remaining <= 0)
|
|
92
|
+
return 0;
|
|
93
|
+
return remaining / dynamicRate;
|
|
94
|
+
}
|
|
82
95
|
// Estimate time to full charge
|
|
83
96
|
const remaining = 100 - batteryLevel;
|
|
84
97
|
if (remaining <= 0)
|
|
@@ -88,6 +101,10 @@ export function estimateBatteryLife(batteryLevel, isCharging, liquidState) {
|
|
|
88
101
|
if (batteryLevel <= 0) {
|
|
89
102
|
return 0;
|
|
90
103
|
}
|
|
104
|
+
// Use dynamic rate if provided and discharging
|
|
105
|
+
if (dynamicRate && dynamicRate < -0.01) {
|
|
106
|
+
return batteryLevel / Math.abs(dynamicRate);
|
|
107
|
+
}
|
|
91
108
|
// Determine drain rate based on mug state
|
|
92
109
|
let drainRate;
|
|
93
110
|
switch (liquidState) {
|
|
@@ -103,6 +120,28 @@ export function estimateBatteryLife(batteryLevel, isCharging, liquidState) {
|
|
|
103
120
|
}
|
|
104
121
|
return batteryLevel / drainRate;
|
|
105
122
|
}
|
|
123
|
+
export function calculateRate(history, windowMs = 2 * 60 * 1000) {
|
|
124
|
+
if (history.length < 2)
|
|
125
|
+
return 0;
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const windowStart = now - windowMs;
|
|
128
|
+
const windowHistory = history.filter((item) => item.time >= windowStart);
|
|
129
|
+
if (windowHistory.length < 2) {
|
|
130
|
+
// If not enough data in the current window, use the last two points overall if they are recent enough
|
|
131
|
+
const last = history[history.length - 1];
|
|
132
|
+
const secondLast = history[history.length - 2];
|
|
133
|
+
if (now - last.time > windowMs)
|
|
134
|
+
return 0;
|
|
135
|
+
const valDiff = last.value - secondLast.value;
|
|
136
|
+
const timeDiffMin = (last.time - secondLast.time) / (1000 * 60);
|
|
137
|
+
return timeDiffMin > 0 ? valDiff / timeDiffMin : 0;
|
|
138
|
+
}
|
|
139
|
+
const first = windowHistory[0];
|
|
140
|
+
const last = windowHistory[windowHistory.length - 1];
|
|
141
|
+
const valDiff = last.value - first.value;
|
|
142
|
+
const timeDiffMin = (last.time - first.time) / (1000 * 60);
|
|
143
|
+
return timeDiffMin > 0 ? valDiff / timeDiffMin : 0;
|
|
144
|
+
}
|
|
106
145
|
export function getTemperatureColor(currentTemp, targetTemp) {
|
|
107
146
|
const diff = currentTemp - targetTemp;
|
|
108
147
|
if (Math.abs(diff) < 1) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ember-mug",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "A CLI app for controlling Ember mugs via Bluetooth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
|
+
"postbuild": "chmod +x dist/cli.js",
|
|
18
19
|
"clean": "rm -rf dist",
|
|
19
20
|
"prebuild": "npm run clean",
|
|
20
21
|
"start": "node dist/cli.js",
|