ember-mug 0.1.3 → 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 +22 -15
- package/dist/cli.js +37 -7
- package/dist/components/App.d.ts +1 -1
- package/dist/components/App.js +65 -54
- package/dist/components/BatteryDisplay.d.ts +9 -3
- package/dist/components/BatteryDisplay.js +8 -19
- package/dist/components/ConnectionStatus.d.ts +7 -2
- package/dist/components/ConnectionStatus.js +5 -4
- package/dist/components/Header.d.ts +7 -2
- package/dist/components/Header.js +11 -3
- package/dist/components/HelpDisplay.d.ts +5 -2
- package/dist/components/HelpDisplay.js +8 -3
- package/dist/components/HorizontalRule.d.ts +2 -0
- package/dist/components/HorizontalRule.js +7 -0
- package/dist/components/Panel.d.ts +18 -0
- package/dist/components/Panel.js +57 -0
- package/dist/components/Presets.d.ts +8 -3
- package/dist/components/Presets.js +13 -8
- package/dist/components/SettingsView.d.ts +9 -3
- package/dist/components/SettingsView.js +90 -16
- package/dist/components/TemperatureControl.d.ts +8 -3
- package/dist/components/TemperatureControl.js +13 -18
- package/dist/components/TemperatureDisplay.d.ts +9 -3
- package/dist/components/TemperatureDisplay.js +9 -36
- package/dist/hooks/useMug.d.ts +3 -1
- package/dist/hooks/useMug.js +69 -22
- package/dist/lib/bluetooth.d.ts +2 -0
- package/dist/lib/bluetooth.js +8 -0
- package/dist/lib/mock-bluetooth.d.ts +65 -0
- package/dist/lib/mock-bluetooth.js +214 -0
- package/dist/lib/settings.d.ts +1 -1
- package/dist/lib/settings.js +20 -20
- package/dist/lib/theme.d.ts +135 -0
- package/dist/lib/theme.js +112 -0
- package/dist/lib/types.d.ts +0 -1
- package/dist/lib/types.js +12 -12
- package/dist/lib/utils.d.ts +7 -4
- package/dist/lib/utils.js +63 -40
- package/package.json +3 -1
- package/dist/components/ColorControl.d.ts +0 -9
- package/dist/components/ColorControl.js +0 -71
package/dist/lib/utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TemperatureUnit, LiquidState, MIN_TEMP_CELSIUS, MAX_TEMP_CELSIUS, BATTERY_DRAIN_RATE_HEATING, BATTERY_DRAIN_RATE_MAINTAINING, BATTERY_CHARGE_RATE, } from
|
|
1
|
+
import { TemperatureUnit, LiquidState, MIN_TEMP_CELSIUS, MAX_TEMP_CELSIUS, BATTERY_DRAIN_RATE_HEATING, BATTERY_DRAIN_RATE_MAINTAINING, BATTERY_CHARGE_RATE, } from "./types.js";
|
|
2
2
|
export function formatTemperature(temp, unit) {
|
|
3
3
|
if (unit === TemperatureUnit.Celsius) {
|
|
4
4
|
return `${temp.toFixed(1)}°C`;
|
|
@@ -13,52 +13,36 @@ export function fahrenheitToCelsius(fahrenheit) {
|
|
|
13
13
|
return ((fahrenheit - 32) * 5) / 9;
|
|
14
14
|
}
|
|
15
15
|
export function formatBatteryLevel(level) {
|
|
16
|
-
return `${level}%`;
|
|
16
|
+
return `${Math.round(level)}%`;
|
|
17
17
|
}
|
|
18
18
|
export function getBatteryIcon(level, isCharging) {
|
|
19
19
|
if (isCharging) {
|
|
20
|
-
return
|
|
20
|
+
return "~";
|
|
21
21
|
}
|
|
22
22
|
if (level >= 75)
|
|
23
|
-
return
|
|
23
|
+
return "||||";
|
|
24
24
|
if (level >= 50)
|
|
25
|
-
return
|
|
25
|
+
return "|||.";
|
|
26
26
|
if (level >= 25)
|
|
27
|
-
return
|
|
27
|
+
return "||..";
|
|
28
28
|
if (level >= 10)
|
|
29
|
-
return
|
|
30
|
-
return
|
|
29
|
+
return "|...";
|
|
30
|
+
return "....";
|
|
31
31
|
}
|
|
32
32
|
export function getLiquidStateText(state) {
|
|
33
33
|
switch (state) {
|
|
34
34
|
case LiquidState.Empty:
|
|
35
|
-
return
|
|
35
|
+
return "Empty";
|
|
36
36
|
case LiquidState.Filling:
|
|
37
|
-
return
|
|
37
|
+
return "Filling";
|
|
38
38
|
case LiquidState.Cooling:
|
|
39
|
-
return
|
|
39
|
+
return "Cooling";
|
|
40
40
|
case LiquidState.Heating:
|
|
41
|
-
return
|
|
41
|
+
return "Heating";
|
|
42
42
|
case LiquidState.StableTemperature:
|
|
43
|
-
return
|
|
43
|
+
return "Perfect Temperature";
|
|
44
44
|
default:
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
export function getLiquidStateIcon(state) {
|
|
49
|
-
switch (state) {
|
|
50
|
-
case LiquidState.Empty:
|
|
51
|
-
return '○';
|
|
52
|
-
case LiquidState.Filling:
|
|
53
|
-
return '◐';
|
|
54
|
-
case LiquidState.Cooling:
|
|
55
|
-
return '❄';
|
|
56
|
-
case LiquidState.Heating:
|
|
57
|
-
return '🔥';
|
|
58
|
-
case LiquidState.StableTemperature:
|
|
59
|
-
return '✓';
|
|
60
|
-
default:
|
|
61
|
-
return '?';
|
|
45
|
+
return "Unknown";
|
|
62
46
|
}
|
|
63
47
|
}
|
|
64
48
|
export function clampTemperature(temp) {
|
|
@@ -66,7 +50,7 @@ export function clampTemperature(temp) {
|
|
|
66
50
|
}
|
|
67
51
|
export function formatDuration(minutes) {
|
|
68
52
|
if (minutes < 1) {
|
|
69
|
-
return
|
|
53
|
+
return "< 1 min";
|
|
70
54
|
}
|
|
71
55
|
if (minutes < 60) {
|
|
72
56
|
return `${Math.round(minutes)} min`;
|
|
@@ -78,7 +62,7 @@ export function formatDuration(minutes) {
|
|
|
78
62
|
}
|
|
79
63
|
return `${hours}h ${mins}m`;
|
|
80
64
|
}
|
|
81
|
-
export function estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState) {
|
|
65
|
+
export function estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState, dynamicRate) {
|
|
82
66
|
if (liquidState === LiquidState.Empty) {
|
|
83
67
|
return null;
|
|
84
68
|
}
|
|
@@ -86,15 +70,28 @@ export function estimateTimeToTargetTemp(currentTemp, targetTemp, liquidState) {
|
|
|
86
70
|
if (tempDiff < 0.5) {
|
|
87
71
|
return 0; // Already at target
|
|
88
72
|
}
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
92
83
|
const isHeating = currentTemp < targetTemp;
|
|
93
84
|
const ratePerMinute = isHeating ? 1.0 : 0.5;
|
|
94
85
|
return tempDiff / ratePerMinute;
|
|
95
86
|
}
|
|
96
|
-
export function estimateBatteryLife(batteryLevel, isCharging, liquidState) {
|
|
87
|
+
export function estimateBatteryLife(batteryLevel, isCharging, liquidState, dynamicRate) {
|
|
97
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
|
+
}
|
|
98
95
|
// Estimate time to full charge
|
|
99
96
|
const remaining = 100 - batteryLevel;
|
|
100
97
|
if (remaining <= 0)
|
|
@@ -104,6 +101,10 @@ export function estimateBatteryLife(batteryLevel, isCharging, liquidState) {
|
|
|
104
101
|
if (batteryLevel <= 0) {
|
|
105
102
|
return 0;
|
|
106
103
|
}
|
|
104
|
+
// Use dynamic rate if provided and discharging
|
|
105
|
+
if (dynamicRate && dynamicRate < -0.01) {
|
|
106
|
+
return batteryLevel / Math.abs(dynamicRate);
|
|
107
|
+
}
|
|
107
108
|
// Determine drain rate based on mug state
|
|
108
109
|
let drainRate;
|
|
109
110
|
switch (liquidState) {
|
|
@@ -119,15 +120,37 @@ export function estimateBatteryLife(batteryLevel, isCharging, liquidState) {
|
|
|
119
120
|
}
|
|
120
121
|
return batteryLevel / drainRate;
|
|
121
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
|
+
}
|
|
122
145
|
export function getTemperatureColor(currentTemp, targetTemp) {
|
|
123
146
|
const diff = currentTemp - targetTemp;
|
|
124
147
|
if (Math.abs(diff) < 1) {
|
|
125
|
-
return
|
|
148
|
+
return "green"; // At target
|
|
126
149
|
}
|
|
127
150
|
if (diff > 0) {
|
|
128
|
-
return
|
|
151
|
+
return "red"; // Too hot
|
|
129
152
|
}
|
|
130
|
-
return
|
|
153
|
+
return "blue"; // Too cold
|
|
131
154
|
}
|
|
132
155
|
export function interpolateColor(value, minColor, maxColor) {
|
|
133
156
|
const clampedValue = Math.max(0, Math.min(1, value));
|
|
@@ -138,7 +161,7 @@ export function interpolateColor(value, minColor, maxColor) {
|
|
|
138
161
|
];
|
|
139
162
|
}
|
|
140
163
|
export function rgbToHex(r, g, b) {
|
|
141
|
-
return `#${[r, g, b].map((c) => c.toString(16).padStart(2,
|
|
164
|
+
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
|
142
165
|
}
|
|
143
166
|
export function hexToRgb(hex) {
|
|
144
167
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ember-mug",
|
|
3
|
-
"version": "0.1.
|
|
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,10 +15,12 @@
|
|
|
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",
|
|
21
22
|
"dev": "tsx src/cli.tsx",
|
|
23
|
+
"dev-mocked": "EMBER_MOCK=true tsx src/cli.tsx",
|
|
22
24
|
"prepublishOnly": "npm run build",
|
|
23
25
|
"version": "npm run build"
|
|
24
26
|
},
|
|
@@ -1,9 +0,0 @@
|
|
|
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 {};
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
}
|