ember-mug 0.1.4 → 0.1.5

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 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 current mug state
45
- - **LED Color Control**: Customize your mug's LED color with presets or custom RGB values
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
- - `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
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
@@ -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);
@@ -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, 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, }) {
@@ -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 = PRESET_COLORS.findIndex((p) => p.color.r === ledColor.r &&
51
- p.color.g === ledColor.g &&
52
- p.color.b === ledColor.b);
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 = PRESET_COLORS.find((p) => p.color.r === ledColor.r &&
87
- p.color.g === ledColor.g &&
88
- p.color.b === ledColor.b);
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
- : rgbToHex(ledColor.r, ledColor.g, ledColor.b);
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: rgbToHex(ledColor.r, ledColor.g, ledColor.b), 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
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" })] }))] }) })] }));
@@ -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 {};
@@ -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
  }
@@ -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
- // Estimate based on typical Ember mug heating/cooling rates
74
- // Heating: approximately 1°C per minute
75
- // Cooling: approximately 0.5°C per minute (slower due to insulation)
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.4",
3
+ "version": "0.1.5",
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",