From bd8855648f1c934e30aef901a4d3e27b7fe2899a Mon Sep 17 00:00:00 2001 From: JackSwitzer Date: Mon, 19 Jan 2026 13:27:25 -0500 Subject: [PATCH 1/6] Add Textual TUI for interactive alarm configuration - New sol_tui.py with Textual framework for flicker-free terminal UI - Dark background, arrow key navigation, animated sunrise preview - Press 'A' for animation, Enter to start alarm in new Terminal - Integrates with existing main.py (runs TUI when no command given) - Added textual>=3.0.0 dependency Co-Authored-By: Claude Opus 4.5 --- main.py | 4 +- pyproject.toml | 1 + sol_tui.py | 541 +++++++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 66 ++++++ 4 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 sol_tui.py diff --git a/main.py b/main.py index fe8be88..9bbf84f 100644 --- a/main.py +++ b/main.py @@ -1029,7 +1029,9 @@ def add_common_args(p, include_profile=True, include_auto_off=True): args = parser.parse_args() if not args.command: - parser.print_help() + # Run the Textual TUI when no command provided + from sol_tui import run_tui + run_tui(bulb_ip=DEFAULT_BULB_IP) return asyncio.run(args.func(args)) diff --git a/pyproject.toml b/pyproject.toml index dc6b232..1e2a9d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "openai>=2.11.0", "python-kasa>=0.10.2", "rich>=14.2.0", + "textual>=3.0.0", ] [tool.uv.workspace] diff --git a/sol_tui.py b/sol_tui.py new file mode 100644 index 0000000..06222fc --- /dev/null +++ b/sol_tui.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +"""Sol Sunrise Alarm - Textual TUI. + +A beautiful, flicker-free terminal interface for configuring sunrise alarms. +""" + +import asyncio +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path + +from kasa import Device, Module +from rich.text import Text +from textual import on, work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Center, Container, Vertical +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import Footer, Static + +# Default configuration +DEFAULT_BULB_IP = "192.168.1.77" + +# Duration options (minutes) +DURATION_OPTIONS = [20, 30, 45] + +# End temperature options (Kelvin) +END_TEMP_OPTIONS = [4000, 4500, 5000, 5500, 6000, 6500] + +# ASCII sun art for header +SUN_ASCII = """ + \\ \u2502 / + \\ \u2502 / + \u2500\u2500\u2500\u2500(\u2609)\u2500\u2500\u2500\u2500 + / \u2502 \\ + / \u2502 \\ +""" + +SUN_ASCII_SMALL = """ \\ \u2502 / + \u2500\u2500\u2500(\u2609)\u2500\u2500\u2500 + / \u2502 \\""" + +# Animation sun art (same as main.py) +ANIMATION_SUN = [ + " \u2502 ", + " \\ \u2502 / ", + " \\ \u2502 / ", + " \\ \u2502 / ", + " \u2500\u2500\u2500\u2500\u2500\u2609\u2500\u2500\u2500\u2500\u2500 ", + " / \u2502 \\ ", + " / \u2502 \\ ", + " / \u2502 \\ ", + " \u2502 ", +] + + +class ConfigField(Static): + """A single configurable field with label and value.""" + + selected = reactive(False) + + def __init__( + self, + label: str, + value: str, + field_id: str, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.label = label + self._value = value + self.field_id = field_id + + @property + def value(self) -> str: + return self._value + + @value.setter + def value(self, new_value: str) -> None: + self._value = new_value + self.refresh() + + def render(self) -> Text: + text = Text() + if self.selected: + text.append(" > ", style="bold yellow") + text.append(f"{self.label}: ", style="bold yellow") + text.append(f"{self._value}", style="bold bright_yellow") + text.append(" <", style="bold yellow") + else: + text.append(" ", style="dim") + text.append(f"{self.label}: ", style="dim white") + text.append(f"{self._value}", style="white") + return text + + def watch_selected(self, selected: bool) -> None: + self.refresh() + + +class StatusDisplay(Static): + """Displays lamp connection status.""" + + status = reactive("Checking...") + + def render(self) -> Text: + text = Text() + if self.status == "Connected": + text.append(" Lamp: ", style="dim") + text.append("Connected", style="green") + elif self.status == "Checking...": + text.append(" Lamp: ", style="dim") + text.append("Checking...", style="yellow") + else: + text.append(" Lamp: ", style="dim") + text.append("Not Found", style="red") + return text + + +class SunriseInfo(Static): + """Displays calculated sunrise start time.""" + + wake_time: str = "07:00" + duration: int = 30 + + def update_info(self, wake_time: str, duration: int) -> None: + self.wake_time = wake_time + self.duration = duration + self.refresh() + + def render(self) -> Text: + try: + hour, minute = map(int, self.wake_time.split(":")) + wake_dt = datetime.now().replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) + start_dt = wake_dt - timedelta(minutes=self.duration) + start_time = start_dt.strftime("%H:%M") + except (ValueError, AttributeError): + start_time = "--:--" + + text = Text() + text.append("\n Sunrise starts: ", style="dim") + text.append(f"{start_time}", style="orange1") + text.append(" -> Wake: ", style="dim") + text.append(f"{self.wake_time}", style="bright_yellow") + return text + + +class SunHeader(Static): + """ASCII sun art header.""" + + def render(self) -> Text: + text = Text() + for line in SUN_ASCII_SMALL.split("\n"): + text.append(line + "\n", style="bold bright_yellow") + text.append("\n") + text.append(" Sol", style="bold bright_yellow") + text.append(" - Sunrise Alarm\n", style="dim yellow") + return text + + +class AnimationScreen(Screen): + """Full-screen sunrise animation.""" + + BINDINGS = [ + Binding("escape", "dismiss", "Exit"), + Binding("q", "dismiss", "Exit"), + ] + + def compose(self) -> ComposeResult: + yield Static(id="animation-canvas") + + async def on_mount(self) -> None: + self.run_animation() + + @work + async def run_animation(self) -> None: + """Run the sunrise animation.""" + canvas = self.query_one("#animation-canvas", Static) + sun_art = ANIMATION_SUN + sun_height = len(sun_art) + + # Messages + msg1 = "Let there be light." + msg2 = "Good Morning, welcome to the game!" + now = datetime.now() + msg3 = f"\u2600 {now.strftime('%A, %B %d')} \u2022 {now.strftime('%H:%M')}" + + # Get terminal size + width = self.app.size.width + height = self.app.size.height - 2 + + # Calculate positions + msg_row = height // 2 + sun_final_row = 3 + + # Sun starts at bottom, rises to top + start_pos = height - 6 + end_pos = sun_final_row + total_frames = start_pos - end_pos + + border_color = "bright_yellow" + sun_color = "bright_yellow" + sky_bg = "grey27" + + revealed = [False, False, False] + + for frame in range(total_frames + 1): + sun_row = max(end_pos, start_pos - frame) + + # Check if sun passed message rows + if sun_row < msg_row - 2: + revealed[0] = True + if sun_row < msg_row: + revealed[1] = True + if sun_row < msg_row + 2: + revealed[2] = True + + # Build the frame + lines = [] + + # Top border + lines.append(("\u2588" * width, border_color)) + + for row in range(1, height - 1): + is_sun_row = sun_row <= row < sun_row + sun_height + sun_idx = row - sun_row + + line_text = Text() + line_text.append("\u2588", style=border_color) + + if is_sun_row and 0 <= sun_idx < sun_height: + sun_line = sun_art[sun_idx] + pad = (width - 2 - len(sun_line)) // 2 + line_text.append(" " * pad, style=f"on {sky_bg}") + line_text.append(sun_line, style=f"{sun_color} on {sky_bg}") + remaining = width - 2 - pad - len(sun_line) + line_text.append(" " * remaining, style=f"on {sky_bg}") + elif row == msg_row - 2 and revealed[0]: + pad = (width - 2 - len(msg1)) // 2 + line_text.append(" " * pad, style=f"on {sky_bg}") + line_text.append(msg1, style=f"bold bright_yellow on {sky_bg}") + remaining = width - 2 - pad - len(msg1) + line_text.append(" " * remaining, style=f"on {sky_bg}") + elif row == msg_row and revealed[1]: + pad = (width - 2 - len(msg2)) // 2 + line_text.append(" " * pad, style=f"on {sky_bg}") + line_text.append(msg2, style=f"italic orange1 on {sky_bg}") + remaining = width - 2 - pad - len(msg2) + line_text.append(" " * remaining, style=f"on {sky_bg}") + elif row == msg_row + 2 and revealed[2]: + pad = (width - 2 - len(msg3)) // 2 + line_text.append(" " * pad, style=f"on {sky_bg}") + line_text.append(msg3, style=f"dim on {sky_bg}") + remaining = width - 2 - pad - len(msg3) + line_text.append(" " * remaining, style=f"on {sky_bg}") + else: + line_text.append(" " * (width - 2), style=f"on {sky_bg}") + + line_text.append("\u2588", style=border_color) + lines.append(line_text) + + # Bottom border + lines.append(Text("\u2588" * width, style=border_color)) + + # Combine all lines + full_text = Text() + for i, line in enumerate(lines): + if isinstance(line, tuple): + full_text.append(line[0], style=line[1]) + else: + full_text.append(line) + if i < len(lines) - 1: + full_text.append("\n") + + canvas.update(full_text) + await asyncio.sleep(0.08) + + +class SolApp(App): + """Sol Sunrise Alarm TUI.""" + + CSS = """ + Screen { + background: $surface; + } + + #main-container { + width: 100%; + height: 100%; + align: center middle; + } + + #config-panel { + width: 50; + height: auto; + padding: 1 2; + border: round $primary; + background: $surface-darken-1; + } + + #sun-header { + text-align: center; + margin-bottom: 1; + } + + .config-field { + height: 1; + margin: 0 0; + } + + #status-display { + margin-top: 1; + } + + #sunrise-info { + margin-top: 0; + } + + #instructions { + margin-top: 1; + text-align: center; + } + + Footer { + background: $surface-darken-2; + } + + AnimationScreen { + background: #3d3d3d; + } + + #animation-canvas { + width: 100%; + height: 100%; + } + """ + + BINDINGS = [ + Binding("up", "move_up", "Up"), + Binding("down", "move_down", "Down"), + Binding("left", "adjust_left", "Decrease"), + Binding("right", "adjust_right", "Increase"), + Binding("enter", "confirm", "Start Alarm"), + Binding("a", "animate", "Animation"), + Binding("q", "quit", "Quit"), + ] + + # Reactive state + current_field = reactive(0) + wake_time = reactive("07:00") + duration_idx = reactive(1) # Index into DURATION_OPTIONS (default 30) + end_temp_idx = reactive(0) # Index into END_TEMP_OPTIONS (default 4000) + lamp_status = reactive("Checking...") + + def __init__(self, bulb_ip: str = DEFAULT_BULB_IP) -> None: + super().__init__() + self.bulb_ip = bulb_ip + self.fields: list[ConfigField] = [] + + def compose(self) -> ComposeResult: + with Center(id="main-container"): + with Container(id="config-panel"): + yield SunHeader(id="sun-header") + yield ConfigField( + "Wake Time", + self.wake_time, + "wake_time", + classes="config-field", + ) + yield ConfigField( + "Duration", + f"{DURATION_OPTIONS[self.duration_idx]} min", + "duration", + classes="config-field", + ) + yield ConfigField( + "End Temp", + f"{END_TEMP_OPTIONS[self.end_temp_idx]}K", + "end_temp", + classes="config-field", + ) + yield StatusDisplay(id="status-display") + yield SunriseInfo(id="sunrise-info") + yield Static( + "\n[dim]Arrow keys to adjust, Enter to start[/dim]", + id="instructions", + ) + yield Footer() + + async def on_mount(self) -> None: + """Initialize the app on mount.""" + # Get field references + self.fields = list(self.query(ConfigField)) + + # Select the first field + if self.fields: + self.fields[0].selected = True + + # Update sunrise info + self._update_sunrise_info() + + # Check lamp connection + self.check_lamp_connection() + + @work(exclusive=True) + async def check_lamp_connection(self) -> None: + """Check if the lamp is reachable.""" + status_widget = self.query_one(StatusDisplay) + try: + bulb = await Device.connect(host=self.bulb_ip) + await bulb.update() + status_widget.status = "Connected" + except Exception: + status_widget.status = "Not Found" + + def watch_current_field(self, old: int, new: int) -> None: + """Update field selection when current_field changes.""" + if self.fields: + if 0 <= old < len(self.fields): + self.fields[old].selected = False + if 0 <= new < len(self.fields): + self.fields[new].selected = True + + def _update_field_displays(self) -> None: + """Update all field display values.""" + if len(self.fields) >= 3: + self.fields[0].value = self.wake_time + self.fields[1].value = f"{DURATION_OPTIONS[self.duration_idx]} min" + self.fields[2].value = f"{END_TEMP_OPTIONS[self.end_temp_idx]}K" + + def _update_sunrise_info(self) -> None: + """Update the sunrise info display.""" + info = self.query_one(SunriseInfo) + info.update_info(self.wake_time, DURATION_OPTIONS[self.duration_idx]) + + def action_move_up(self) -> None: + """Move selection up.""" + if self.current_field > 0: + self.current_field -= 1 + + def action_move_down(self) -> None: + """Move selection down.""" + if self.current_field < len(self.fields) - 1: + self.current_field += 1 + + def action_adjust_left(self) -> None: + """Decrease the current field value.""" + if self.current_field == 0: + # Wake time: decrease by 5 minutes + self._adjust_wake_time(-5) + elif self.current_field == 1: + # Duration: previous option + if self.duration_idx > 0: + self.duration_idx -= 1 + elif self.current_field == 2: + # End temp: previous option + if self.end_temp_idx > 0: + self.end_temp_idx -= 1 + + self._update_field_displays() + self._update_sunrise_info() + + def action_adjust_right(self) -> None: + """Increase the current field value.""" + if self.current_field == 0: + # Wake time: increase by 5 minutes + self._adjust_wake_time(5) + elif self.current_field == 1: + # Duration: next option + if self.duration_idx < len(DURATION_OPTIONS) - 1: + self.duration_idx += 1 + elif self.current_field == 2: + # End temp: next option + if self.end_temp_idx < len(END_TEMP_OPTIONS) - 1: + self.end_temp_idx += 1 + + self._update_field_displays() + self._update_sunrise_info() + + def _adjust_wake_time(self, delta_minutes: int) -> None: + """Adjust wake time by delta_minutes.""" + try: + hour, minute = map(int, self.wake_time.split(":")) + dt = datetime.now().replace(hour=hour, minute=minute) + dt += timedelta(minutes=delta_minutes) + self.wake_time = dt.strftime("%H:%M") + except ValueError: + pass + + def action_animate(self) -> None: + """Show the sunrise animation.""" + self.push_screen(AnimationScreen()) + + def action_confirm(self) -> None: + """Confirm settings and start the alarm.""" + self.start_alarm() + + @work + async def start_alarm(self) -> None: + """Turn off lamp and launch alarm in new terminal.""" + # Turn off lamp first + try: + bulb = await Device.connect(host=self.bulb_ip) + await bulb.turn_off() + except Exception: + pass # Continue even if lamp not found + + # Build the command + duration = DURATION_OPTIONS[self.duration_idx] + # Map duration to profile + profile_map = {20: "quick", 30: "standard", 45: "gentle"} + profile = profile_map.get(duration, "standard") + + main_py = Path(__file__).parent / "main.py" + cmd = f"uv run python {main_py} up {self.wake_time} -p {profile}" + + # Launch in new Terminal with caffeinate + apple_script = f''' + tell application "Terminal" + activate + do script "caffeinate -d {cmd}" + end tell + ''' + + subprocess.run(["osascript", "-e", apple_script], check=False) + + # Exit the TUI + self.exit() + + +def run_tui(bulb_ip: str = DEFAULT_BULB_IP) -> None: + """Run the Sol TUI application.""" + app = SolApp(bulb_ip=bulb_ip) + app.run() + + +if __name__ == "__main__": + run_tui() diff --git a/uv.lock b/uv.lock index c7c6989..be5bfa5 100644 --- a/uv.lock +++ b/uv.lock @@ -158,6 +158,7 @@ dependencies = [ { name = "openai" }, { name = "python-kasa" }, { name = "rich" }, + { name = "textual" }, ] [package.metadata] @@ -167,6 +168,7 @@ requires-dist = [ { name = "openai", specifier = ">=2.11.0" }, { name = "python-kasa", specifier = ">=0.10.2" }, { name = "rich", specifier = ">=14.2.0" }, + { name = "textual", specifier = ">=3.0.0" }, ] [[package]] @@ -568,6 +570,18 @@ requires-dist = [ { name = "python-kasa", specifier = ">=0.10.2" }, ] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -580,6 +594,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -669,6 +688,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/0d/5cf14e177c8ae655a2fd9324a6ef657ca4cafd3fc2201c87716055e29641/mcp-1.24.0-py3-none-any.whl", hash = "sha256:db130e103cc50ddc3dffc928382f33ba3eaef0b711f7a87c05e7ded65b1ca062", size = 232896, upload-time = "2025-12-12T14:19:36.14Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -778,6 +809,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/f1/d9251b565fce9f8daeb45611e3e0d2f7f248429e40908dcee3b6fe1b5944/openai-2.11.0-py3-none-any.whl", hash = "sha256:21189da44d2e3d027b08c7a920ba4454b8b7d6d30ae7e64d9de11dbe946d4faa", size = 1064131, upload-time = "2025-12-11T19:11:56.816Z" }, ] +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1134,6 +1174,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "textual" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/ee/620c887bfad9d6eba062dfa3b6b0e735e0259102e2667b19f21625ef598d/textual-7.3.0.tar.gz", hash = "sha256:3169e8ba5518a979b0771e60be380ab1a6c344f30a2126e360e6f38d009a3de4", size = 1590692, upload-time = "2026-01-15T16:32:02.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/1f/abeb4e5cb36b99dd37db72beb2a74d58598ccb35aaadf14624ee967d4a6b/textual-7.3.0-py3-none-any.whl", hash = "sha256:db235cecf969c87fe5a9c04d83595f506affc9db81f3a53ab849534d726d330a", size = 716374, upload-time = "2026-01-15T16:31:58.233Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1176,6 +1233,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + [[package]] name = "uvicorn" version = "0.38.0" From e2400d4c56057bfd27720776b4e496419e5b30f2 Mon Sep 17 00:00:00 2001 From: JackSwitzer Date: Mon, 19 Jan 2026 14:19:53 -0500 Subject: [PATCH 2/6] Fix TUI to run sunrise in same terminal - Remove AppleScript that opened new terminal - Use os.execvp to replace process with caffeinate + sunrise command - Properly change to lamp directory before running - Stays in same terminal window for cleaner UX Co-Authored-By: Claude Opus 4.5 --- sol_tui.py | 46 +++++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/sol_tui.py b/sol_tui.py index 06222fc..5648ac0 100644 --- a/sol_tui.py +++ b/sol_tui.py @@ -5,7 +5,6 @@ """ import asyncio -import subprocess import sys from datetime import datetime, timedelta from pathlib import Path @@ -500,7 +499,7 @@ def action_confirm(self) -> None: @work async def start_alarm(self) -> None: - """Turn off lamp and launch alarm in new terminal.""" + """Turn off lamp and start alarm in same terminal.""" # Turn off lamp first try: bulb = await Device.connect(host=self.bulb_ip) @@ -508,33 +507,42 @@ async def start_alarm(self) -> None: except Exception: pass # Continue even if lamp not found - # Build the command + # Build the command args duration = DURATION_OPTIONS[self.duration_idx] - # Map duration to profile profile_map = {20: "quick", 30: "standard", 45: "gentle"} profile = profile_map.get(duration, "standard") - main_py = Path(__file__).parent / "main.py" - cmd = f"uv run python {main_py} up {self.wake_time} -p {profile}" + # Store command info for after exit + self.app_result = { + "wake_time": self.wake_time, + "profile": profile, + } - # Launch in new Terminal with caffeinate - apple_script = f''' - tell application "Terminal" - activate - do script "caffeinate -d {cmd}" - end tell - ''' - - subprocess.run(["osascript", "-e", apple_script], check=False) - - # Exit the TUI - self.exit() + # Exit the TUI - command will run after + self.exit(self.app_result) def run_tui(bulb_ip: str = DEFAULT_BULB_IP) -> None: """Run the Sol TUI application.""" + import os + app = SolApp(bulb_ip=bulb_ip) - app.run() + result = app.run() + + # If user confirmed alarm, run the sunrise command in same terminal + if result and isinstance(result, dict) and "wake_time" in result: + wake_time = result["wake_time"] + profile = result["profile"] + + # Change to lamp directory and run with caffeinate + lamp_dir = Path(__file__).parent + os.chdir(lamp_dir) + + # Replace this process with the sunrise command + os.execvp("caffeinate", [ + "caffeinate", "-d", + "uv", "run", "python", "main.py", "up", wake_time, "-p", profile + ]) if __name__ == "__main__": From e9d0b32ede4fcd809dfe7931e3637c0c6d3b31ec Mon Sep 17 00:00:00 2001 From: JackSwitzer Date: Mon, 19 Jan 2026 14:21:23 -0500 Subject: [PATCH 3/6] Transition to animation on Enter instead of exiting - Press Enter now shows sunrise animation first - Lamp turns off, animation plays - After animation dismisses, runs actual sunrise command Co-Authored-By: Claude Opus 4.5 --- sol_tui.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/sol_tui.py b/sol_tui.py index 5648ac0..b303545 100644 --- a/sol_tui.py +++ b/sol_tui.py @@ -499,7 +499,7 @@ def action_confirm(self) -> None: @work async def start_alarm(self) -> None: - """Turn off lamp and start alarm in same terminal.""" + """Turn off lamp and transition to sunrise animation.""" # Turn off lamp first try: bulb = await Device.connect(host=self.bulb_ip) @@ -507,18 +507,21 @@ async def start_alarm(self) -> None: except Exception: pass # Continue even if lamp not found - # Build the command args + # Store settings for after animation duration = DURATION_OPTIONS[self.duration_idx] profile_map = {20: "quick", 30: "standard", 45: "gentle"} - profile = profile_map.get(duration, "standard") + self._pending_profile = profile_map.get(duration, "standard") + self._pending_wake_time = self.wake_time - # Store command info for after exit + # Transition to animation screen + self.push_screen(AnimationScreen(), callback=self._on_animation_complete) + + def _on_animation_complete(self, result: None) -> None: + """Called when animation screen is dismissed - start the actual sunrise.""" self.app_result = { - "wake_time": self.wake_time, - "profile": profile, + "wake_time": self._pending_wake_time, + "profile": self._pending_profile, } - - # Exit the TUI - command will run after self.exit(self.app_result) From 14d87a43eccf3e3f1107983ad6700c3024494e12 Mon Sep 17 00:00:00 2001 From: JackSwitzer Date: Mon, 19 Jan 2026 14:24:15 -0500 Subject: [PATCH 4/6] Improve TUI: profiles, increments, double-escape - Add profile selection at top (standard/quick/gentle/custom) - Wake time now uses 10 min increments - Duration now uses 5 min increments (10-60 min range) - Double-escape required to exit animation screen - Switching profile applies its preset duration/temp - Manual adjustment auto-switches to custom profile Co-Authored-By: Claude Opus 4.5 --- sol_tui.py | 119 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 24 deletions(-) diff --git a/sol_tui.py b/sol_tui.py index b303545..73befc6 100644 --- a/sol_tui.py +++ b/sol_tui.py @@ -22,8 +22,18 @@ # Default configuration DEFAULT_BULB_IP = "192.168.1.77" -# Duration options (minutes) -DURATION_OPTIONS = [20, 30, 45] +# Profiles (presets) +PROFILES = { + "standard": {"duration": 30, "end_temp": 5000}, + "quick": {"duration": 20, "end_temp": 4000}, + "gentle": {"duration": 45, "end_temp": 4000}, + "custom": {"duration": 30, "end_temp": 5000}, +} +PROFILE_ORDER = ["standard", "quick", "gentle", "custom"] + +# Duration range (5 min increments) +DURATION_MIN = 10 +DURATION_MAX = 60 # End temperature options (Kelvin) END_TEMP_OPTIONS = [4000, 4500, 5000, 5500, 6000, 6500] @@ -164,16 +174,34 @@ class AnimationScreen(Screen): """Full-screen sunrise animation.""" BINDINGS = [ - Binding("escape", "dismiss", "Exit"), + Binding("escape", "handle_escape", "Back (2x)"), Binding("q", "dismiss", "Exit"), ] + def __init__(self) -> None: + super().__init__() + self._escape_count = 0 + self._escape_timer = None + def compose(self) -> ComposeResult: yield Static(id="animation-canvas") async def on_mount(self) -> None: self.run_animation() + def action_handle_escape(self) -> None: + """Handle escape - need double press to exit.""" + self._escape_count += 1 + if self._escape_count >= 2: + self.dismiss() + else: + # Reset after 1 second + self.set_timer(1.0, self._reset_escape_count) + + def _reset_escape_count(self) -> None: + """Reset escape counter.""" + self._escape_count = 0 + @work async def run_animation(self) -> None: """Run the sunrise animation.""" @@ -349,9 +377,10 @@ class SolApp(App): # Reactive state current_field = reactive(0) + profile_idx = reactive(0) # Index into PROFILE_ORDER (default standard) wake_time = reactive("07:00") - duration_idx = reactive(1) # Index into DURATION_OPTIONS (default 30) - end_temp_idx = reactive(0) # Index into END_TEMP_OPTIONS (default 4000) + duration = reactive(30) # Minutes (5 min increments) + end_temp_idx = reactive(2) # Index into END_TEMP_OPTIONS (default 5000) lamp_status = reactive("Checking...") def __init__(self, bulb_ip: str = DEFAULT_BULB_IP) -> None: @@ -363,6 +392,12 @@ def compose(self) -> ComposeResult: with Center(id="main-container"): with Container(id="config-panel"): yield SunHeader(id="sun-header") + yield ConfigField( + "Profile", + PROFILE_ORDER[self.profile_idx].title(), + "profile", + classes="config-field", + ) yield ConfigField( "Wake Time", self.wake_time, @@ -371,7 +406,7 @@ def compose(self) -> ComposeResult: ) yield ConfigField( "Duration", - f"{DURATION_OPTIONS[self.duration_idx]} min", + f"{self.duration} min", "duration", classes="config-field", ) @@ -425,15 +460,16 @@ def watch_current_field(self, old: int, new: int) -> None: def _update_field_displays(self) -> None: """Update all field display values.""" - if len(self.fields) >= 3: - self.fields[0].value = self.wake_time - self.fields[1].value = f"{DURATION_OPTIONS[self.duration_idx]} min" - self.fields[2].value = f"{END_TEMP_OPTIONS[self.end_temp_idx]}K" + if len(self.fields) >= 4: + self.fields[0].value = PROFILE_ORDER[self.profile_idx].title() + self.fields[1].value = self.wake_time + self.fields[2].value = f"{self.duration} min" + self.fields[3].value = f"{END_TEMP_OPTIONS[self.end_temp_idx]}K" def _update_sunrise_info(self) -> None: """Update the sunrise info display.""" info = self.query_one(SunriseInfo) - info.update_info(self.wake_time, DURATION_OPTIONS[self.duration_idx]) + info.update_info(self.wake_time, self.duration) def action_move_up(self) -> None: """Move selection up.""" @@ -448,16 +484,22 @@ def action_move_down(self) -> None: def action_adjust_left(self) -> None: """Decrease the current field value.""" if self.current_field == 0: - # Wake time: decrease by 5 minutes - self._adjust_wake_time(-5) + # Profile: cycle backwards + self.profile_idx = (self.profile_idx - 1) % len(PROFILE_ORDER) + self._apply_profile() elif self.current_field == 1: - # Duration: previous option - if self.duration_idx > 0: - self.duration_idx -= 1 + # Wake time: decrease by 10 minutes + self._adjust_wake_time(-10) elif self.current_field == 2: + # Duration: decrease by 5 min (min 10) + if self.duration > DURATION_MIN: + self.duration -= 5 + self._set_custom_profile() + elif self.current_field == 3: # End temp: previous option if self.end_temp_idx > 0: self.end_temp_idx -= 1 + self._set_custom_profile() self._update_field_displays() self._update_sunrise_info() @@ -465,20 +507,39 @@ def action_adjust_left(self) -> None: def action_adjust_right(self) -> None: """Increase the current field value.""" if self.current_field == 0: - # Wake time: increase by 5 minutes - self._adjust_wake_time(5) + # Profile: cycle forwards + self.profile_idx = (self.profile_idx + 1) % len(PROFILE_ORDER) + self._apply_profile() elif self.current_field == 1: - # Duration: next option - if self.duration_idx < len(DURATION_OPTIONS) - 1: - self.duration_idx += 1 + # Wake time: increase by 10 minutes + self._adjust_wake_time(10) elif self.current_field == 2: + # Duration: increase by 5 min (max 60) + if self.duration < DURATION_MAX: + self.duration += 5 + self._set_custom_profile() + elif self.current_field == 3: # End temp: next option if self.end_temp_idx < len(END_TEMP_OPTIONS) - 1: self.end_temp_idx += 1 + self._set_custom_profile() self._update_field_displays() self._update_sunrise_info() + def _apply_profile(self) -> None: + """Apply settings from current profile.""" + profile_name = PROFILE_ORDER[self.profile_idx] + if profile_name != "custom": + profile = PROFILES[profile_name] + self.duration = profile["duration"] + self.end_temp_idx = END_TEMP_OPTIONS.index(profile["end_temp"]) + + def _set_custom_profile(self) -> None: + """Switch to custom profile when manually adjusting settings.""" + if PROFILE_ORDER[self.profile_idx] != "custom": + self.profile_idx = PROFILE_ORDER.index("custom") + def _adjust_wake_time(self, delta_minutes: int) -> None: """Adjust wake time by delta_minutes.""" try: @@ -508,10 +569,20 @@ async def start_alarm(self) -> None: pass # Continue even if lamp not found # Store settings for after animation - duration = DURATION_OPTIONS[self.duration_idx] - profile_map = {20: "quick", 30: "standard", 45: "gentle"} - self._pending_profile = profile_map.get(duration, "standard") + profile_name = PROFILE_ORDER[self.profile_idx] + # Map to closest profile for main.py (custom uses standard as base) + if profile_name == "custom": + # Use closest matching profile based on duration + if self.duration <= 20: + profile_name = "quick" + elif self.duration >= 45: + profile_name = "gentle" + else: + profile_name = "standard" + + self._pending_profile = profile_name self._pending_wake_time = self.wake_time + self._pending_duration = self.duration # Transition to animation screen self.push_screen(AnimationScreen(), callback=self._on_animation_complete) From 6e3a52d42c864aafe68a77956bae8dc7a0eaf0f5 Mon Sep 17 00:00:00 2001 From: JackSwitzer Date: Mon, 19 Jan 2026 14:37:50 -0500 Subject: [PATCH 5/6] Clean up animation: fix messages and border - Change "Good Morning, welcome to the game!" to "Welcome to the game." - Use proper box-drawing characters for clean consistent border - Darker sky background for better contrast Co-Authored-By: Claude Opus 4.5 --- sol-ink/.gitignore | 2 + sol-ink/package.json | 24 ++ sol-ink/sol-ink | 16 ++ sol-ink/src/App.tsx | 304 +++++++++++++++++++++++ sol-ink/src/components/Animation.tsx | 271 ++++++++++++++++++++ sol-ink/src/components/SettingsPanel.tsx | 107 ++++++++ sol-ink/src/components/Sun.tsx | 102 ++++++++ sol-ink/src/hooks/useLampConnection.ts | 87 +++++++ sol-ink/src/index.tsx | 16 ++ sol-ink/tsconfig.json | 20 ++ sol_tui.py | 28 ++- 11 files changed, 967 insertions(+), 10 deletions(-) create mode 100644 sol-ink/.gitignore create mode 100644 sol-ink/package.json create mode 100755 sol-ink/sol-ink create mode 100644 sol-ink/src/App.tsx create mode 100644 sol-ink/src/components/Animation.tsx create mode 100644 sol-ink/src/components/SettingsPanel.tsx create mode 100644 sol-ink/src/components/Sun.tsx create mode 100644 sol-ink/src/hooks/useLampConnection.ts create mode 100644 sol-ink/src/index.tsx create mode 100644 sol-ink/tsconfig.json diff --git a/sol-ink/.gitignore b/sol-ink/.gitignore new file mode 100644 index 0000000..d77474a --- /dev/null +++ b/sol-ink/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lock diff --git a/sol-ink/package.json b/sol-ink/package.json new file mode 100644 index 0000000..7fce598 --- /dev/null +++ b/sol-ink/package.json @@ -0,0 +1,24 @@ +{ + "name": "sol-ink", + "version": "1.0.0", + "description": "Terminal TUI for Sol Sunrise Alarm", + "type": "module", + "main": "src/index.tsx", + "scripts": { + "start": "bun run src/index.tsx", + "dev": "bun --watch run src/index.tsx", + "build": "bun build src/index.tsx --outdir dist --target node" + }, + "dependencies": { + "ink": "^5.0.1", + "ink-select-input": "^6.0.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^18.3.3", + "typescript": "^5.5.3" + } +} diff --git a/sol-ink/sol-ink b/sol-ink/sol-ink new file mode 100755 index 0000000..4e5f5cf --- /dev/null +++ b/sol-ink/sol-ink @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Sol Sunrise Alarm - Terminal UI +# Run with: ./sol-ink + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$SCRIPT_DIR" || exit 1 + +# Check if node_modules exists, if not install +if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + bun install +fi + +# Run the app +exec bun run src/index.tsx "$@" diff --git a/sol-ink/src/App.tsx b/sol-ink/src/App.tsx new file mode 100644 index 0000000..12d784d --- /dev/null +++ b/sol-ink/src/App.tsx @@ -0,0 +1,304 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Text, useApp, useInput, useStdout } from 'ink'; +import Sun from './components/Sun.js'; +import SettingsPanel from './components/SettingsPanel.js'; +import Animation from './components/Animation.js'; +import { useLampConnection } from './hooks/useLampConnection.js'; +import { spawn } from 'child_process'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// Profiles match main.py +const PROFILES = { + quick: { name: 'Quick', duration: 20 }, + standard: { name: 'Standard', duration: 30 }, + gentle: { name: 'Gentle', duration: 45 }, +} as const; + +type ProfileKey = keyof typeof PROFILES; + +interface Settings { + wakeTime: { hour: number; minute: number }; + duration: ProfileKey; + endTemp: number; +} + +const DEFAULT_SETTINGS: Settings = { + wakeTime: { hour: 7, minute: 0 }, + duration: 'standard', + endTemp: 4000, +}; + +// Temperature options (Kelvin) +const TEMP_OPTIONS = [4000, 4500, 5000, 5500, 6000, 6500]; + +// Duration options in order +const DURATION_ORDER: ProfileKey[] = ['quick', 'standard', 'gentle']; + +function formatTime(hour: number, minute: number): string { + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; +} + +function calculateStartTime(settings: Settings): string { + const { hour, minute } = settings.wakeTime; + const duration = PROFILES[settings.duration].duration; + + let startMinute = minute - duration; + let startHour = hour; + + while (startMinute < 0) { + startMinute += 60; + startHour -= 1; + } + if (startHour < 0) startHour += 24; + + return formatTime(startHour, startMinute); +} + +export default function App() { + const { exit } = useApp(); + const { stdout } = useStdout(); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [selectedField, setSelectedField] = useState(0); + const [showAnimation, setShowAnimation] = useState(false); + const { connected, checking, error, checkConnection } = useLampConnection(); + + // Get terminal dimensions + const [dimensions, setDimensions] = useState({ + width: stdout?.columns || 80, + height: stdout?.rows || 24, + }); + + useEffect(() => { + const handleResize = () => { + setDimensions({ + width: stdout?.columns || 80, + height: stdout?.rows || 24, + }); + }; + + stdout?.on('resize', handleResize); + return () => { + stdout?.off('resize', handleResize); + }; + }, [stdout]); + + // Check lamp connection on mount + useEffect(() => { + checkConnection(); + }, []); + + const adjustTime = useCallback((delta: number) => { + setSettings(prev => { + let newMinute = prev.wakeTime.minute + delta; + let newHour = prev.wakeTime.hour; + + while (newMinute >= 60) { + newMinute -= 60; + newHour += 1; + } + while (newMinute < 0) { + newMinute += 60; + newHour -= 1; + } + if (newHour >= 24) newHour -= 24; + if (newHour < 0) newHour += 24; + + return { + ...prev, + wakeTime: { hour: newHour, minute: newMinute }, + }; + }); + }, []); + + const cycleDuration = useCallback((direction: number) => { + setSettings(prev => { + const currentIndex = DURATION_ORDER.indexOf(prev.duration); + let newIndex = currentIndex + direction; + if (newIndex < 0) newIndex = DURATION_ORDER.length - 1; + if (newIndex >= DURATION_ORDER.length) newIndex = 0; + return { ...prev, duration: DURATION_ORDER[newIndex] }; + }); + }, []); + + const adjustTemp = useCallback((direction: number) => { + setSettings(prev => { + const currentIndex = TEMP_OPTIONS.indexOf(prev.endTemp); + let newIndex = currentIndex + direction; + if (newIndex < 0) newIndex = 0; + if (newIndex >= TEMP_OPTIONS.length) newIndex = TEMP_OPTIONS.length - 1; + return { ...prev, endTemp: TEMP_OPTIONS[newIndex] }; + }); + }, []); + + const startAlarm = useCallback(() => { + const wakeTimeStr = formatTime(settings.wakeTime.hour, settings.wakeTime.minute); + const profile = settings.duration; + + // Get the path to the parent directory where main.py lives + const __dirname = dirname(fileURLToPath(import.meta.url)); + const lampDir = resolve(__dirname, '../..'); + + // Build the command to run in a new terminal + const command = `cd "${lampDir}" && caffeinate -d uv run python main.py up ${wakeTimeStr} -p ${profile}`; + + // Open in new Terminal window + const script = `tell application "Terminal" + do script "${command.replace(/"/g, '\\"')}" + activate + end tell`; + + spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }); + + // Exit after launching + setTimeout(() => exit(), 500); + }, [settings, exit]); + + useInput((input, key) => { + if (showAnimation) { + // Any key exits animation + if (input || key.return || key.escape) { + setShowAnimation(false); + } + return; + } + + if (input === 'q' || input === 'Q') { + exit(); + return; + } + + if (input === 'a' || input === 'A') { + setShowAnimation(true); + return; + } + + if (key.return) { + startAlarm(); + return; + } + + // Navigation + if (key.upArrow) { + setSelectedField(prev => (prev - 1 + 3) % 3); + } else if (key.downArrow) { + setSelectedField(prev => (prev + 1) % 3); + } + + // Value adjustment + if (key.leftArrow || key.rightArrow) { + const direction = key.rightArrow ? 1 : -1; + + switch (selectedField) { + case 0: // Wake time + adjustTime(direction * 5); + break; + case 1: // Duration + cycleDuration(direction); + break; + case 2: // End temp + adjustTemp(direction); + break; + } + } + }); + + if (showAnimation) { + return setShowAnimation(false)} />; + } + + const startTime = calculateStartTime(settings); + const wakeTimeStr = formatTime(settings.wakeTime.hour, settings.wakeTime.minute); + + return ( + + {/* Header with Sun */} + + + + Sol + - Sunrise Alarm + + + Gentle wake-up light for Kasa bulbs + + + + {/* Main content area */} + + {/* Settings Panel */} + + + + {/* Calculated start time */} + + Sunrise starts at + {startTime} + -> Wake: + {wakeTimeStr} + + + + {/* Status Panel */} + + Status + + Lamp: + {checking ? ( + Checking... + ) : connected ? ( + Connected + ) : ( + {error || 'Disconnected'} + )} + + + + + {/* Footer with keybindings */} + + + [ + Up/Down + ] Select + + [ + Left/Right + ] Adjust + + [ + Enter + ] Start + + [ + A + ] Animate + + [ + Q + ] Quit + + + + ); +} diff --git a/sol-ink/src/components/Animation.tsx b/sol-ink/src/components/Animation.tsx new file mode 100644 index 0000000..597866f --- /dev/null +++ b/sol-ink/src/components/Animation.tsx @@ -0,0 +1,271 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Text, useInput } from 'ink'; + +interface AnimationProps { + width: number; + height: number; + onComplete: () => void; +} + +// Sun art that grows as it rises +const SUN_STAGES = [ + // Stage 0: Peeking (deep red) + [ + ' . . . ', + ' . (*) . ', + ' . . . ', + ], + // Stage 1: Rising (orange-red) + [ + ' \\ | / ', + ' \\|/ ', + ' ---(@)--- ', + ' /|\\ ', + ' / | \\ ', + ], + // Stage 2: Half-risen (orange) + [ + ' \\ | / ', + ' \\|/ ', + ' \\ | / ', + ' ----(@)---- ', + ' / | \\ ', + ' /|\\ ', + ' / | \\ ', + ], + // Stage 3: Full (golden yellow) + [ + ' | ', + ' \\ | / ', + ' \\ | / ', + ' \\ | / ', + ' -----(@)----- ', + ' / | \\ ', + ' / | \\ ', + ' / | \\ ', + ' | ', + ], +]; + +// Color progression: red -> orange -> yellow -> bright yellow/white +const COLOR_STAGES = [ + { sun: '#8B0000', border: '#4a0000', sky: '#0a0505', text: '#5a2020' }, // Deep red + { sun: '#FF4500', border: '#CC3700', sky: '#1a0a05', text: '#FF6347' }, // Orange-red + { sun: '#FFA500', border: '#CC8400', sky: '#2a1505', text: '#FFD700' }, // Orange + { sun: '#FFD700', border: '#CCAC00', sky: '#3a2510', text: '#FFFF00' }, // Golden + { sun: '#FFFF00', border: '#FFFF00', sky: '#4a3515', text: '#FFFFFF' }, // Bright yellow +]; + +// Messages revealed as sun rises +const MESSAGES = [ + { text: 'Let there be light.', style: 'bold' as const }, + { text: 'Good Morning!', style: 'italic' as const }, +]; + +export default function Animation({ width, height, onComplete }: AnimationProps) { + const [frame, setFrame] = useState(0); + const [exiting, setExiting] = useState(false); + + // Animation constants + const TOTAL_FRAMES = 120; + const FRAME_DELAY = 70; // ms per frame + + useEffect(() => { + const timer = setInterval(() => { + setFrame(prev => { + if (prev >= TOTAL_FRAMES) { + return prev; // Hold at end + } + return prev + 1; + }); + }, FRAME_DELAY); + + return () => clearInterval(timer); + }, []); + + // Handle any key to exit + useInput(() => { + if (!exiting) { + setExiting(true); + setTimeout(onComplete, 100); + } + }); + + // Calculate animation state + const progress = frame / TOTAL_FRAMES; + + // Sun position: starts below horizon, rises to upper third + const horizonY = Math.floor(height * 0.7); + const sunTopEnd = Math.floor(height * 0.12); + const sunBottomStart = horizonY + 4; + + // Eased rise (starts slow, accelerates, slows at end) + const eased = progress < 0.5 + ? 2 * progress * progress + : 1 - Math.pow(-2 * progress + 2, 2) / 2; + + const sunY = Math.floor(sunBottomStart - (sunBottomStart - sunTopEnd) * Math.min(eased * 1.1, 1)); + + // Sun stage based on progress (0-3) + const sunStageIndex = Math.min(Math.floor(progress * 4), 3); + const colorStageIndex = Math.min(Math.floor(progress * 5), 4); + + const sunArt = SUN_STAGES[sunStageIndex]; + const colors = COLOR_STAGES[colorStageIndex]; + + const sunHeight = sunArt.length; + const sunWidth = sunArt[0].length; + const sunX = Math.floor((width - sunWidth) / 2); + + // Message reveal: show when sun passes message position + const messageY = Math.floor(height * 0.4); + const showMessage1 = sunY < messageY - 2; + const showMessage2 = sunY < messageY + 1; + + // Date/time + const now = new Date(); + const dateStr = now.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + }); + const timeStr = now.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + + // Build the scene row by row + const lines: React.ReactNode[] = []; + + // Top border + lines.push( + + {'='.repeat(width)} + + ); + + for (let row = 1; row < height - 2; row++) { + const isSunRow = row >= sunY && row < sunY + sunHeight; + const sunRowIndex = row - sunY; + + // Check for message rows + const isMessage1Row = row === messageY - 2; + const isMessage2Row = row === messageY + 1; + const isDateRow = row === messageY + 4; + + // Horizon line + const isHorizonRow = row === horizonY; + + if (isSunRow && sunRowIndex >= 0 && sunRowIndex < sunHeight) { + // Sun row + const sunLine = sunArt[sunRowIndex]; + const leftPad = Math.max(0, sunX); + const rightPad = Math.max(0, width - leftPad - sunLine.length - 2); + + lines.push( + + | + {' '.repeat(leftPad)} + + {sunLine} + + {' '.repeat(rightPad)} + | + + ); + } else if (isMessage1Row && showMessage1) { + // "Let there be light." + const msg = MESSAGES[0].text; + const leftPad = Math.floor((width - msg.length - 2) / 2); + const rightPad = width - leftPad - msg.length - 2; + + lines.push( + + | + {' '.repeat(leftPad)} + + {msg} + + {' '.repeat(rightPad)} + | + + ); + } else if (isMessage2Row && showMessage2) { + // "Good Morning!" + const msg = MESSAGES[1].text; + const leftPad = Math.floor((width - msg.length - 2) / 2); + const rightPad = width - leftPad - msg.length - 2; + + lines.push( + + | + {' '.repeat(leftPad)} + + {msg} + + {' '.repeat(rightPad)} + | + + ); + } else if (isDateRow && showMessage2) { + // Date and time + const msg = `${dateStr} | ${timeStr}`; + const leftPad = Math.floor((width - msg.length - 2) / 2); + const rightPad = width - leftPad - msg.length - 2; + + lines.push( + + | + {' '.repeat(leftPad)} + + {msg} + + {' '.repeat(rightPad)} + | + + ); + } else if (isHorizonRow) { + // Horizon line with subtle gradient + const horizonChar = progress > 0.3 ? '-' : '.'; + lines.push( + + | + + {horizonChar.repeat(width - 2)} + + | + + ); + } else { + // Empty sky + lines.push( + + | + {' '.repeat(width - 2)} + | + + ); + } + } + + // Bottom border + lines.push( + + {'='.repeat(width)} + + ); + + // Hint + lines.push( + + Press any key to return + + ); + + return ( + + {lines} + + ); +} diff --git a/sol-ink/src/components/SettingsPanel.tsx b/sol-ink/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..34117e7 --- /dev/null +++ b/sol-ink/src/components/SettingsPanel.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Box, Text } from 'ink'; + +interface SettingsPanelProps { + wakeTime: string; + duration: { name: string; duration: number }; + endTemp: number; + selectedField: number; +} + +interface FieldRowProps { + label: string; + value: string; + isSelected: boolean; + hint?: string; +} + +function FieldRow({ label, value, isSelected, hint }: FieldRowProps) { + return ( + + {/* Selection indicator */} + + {isSelected ? ' > ' : ' '} + + + {/* Label */} + + {label.padEnd(12)} + + + {/* Value with brackets indicating adjustable */} + [ + + {` ${value} `} + + ] + + {/* Hint */} + {hint && ( + + {' '}{hint} + + )} + + ); +} + +// Temperature hint based on Kelvin value +function getTempHint(temp: number): string { + if (temp <= 4000) return '(warm amber)'; + if (temp <= 4500) return '(warm white)'; + if (temp <= 5000) return '(neutral)'; + if (temp <= 5500) return '(cool white)'; + return '(daylight)'; +} + +export default function SettingsPanel({ + wakeTime, + duration, + endTemp, + selectedField, +}: SettingsPanelProps) { + return ( + + {/* Header */} + + + Alarm Settings + + + + {/* Divider */} + + {'~'.repeat(32)} + + + {/* Wake Time */} + + + {/* Duration */} + + + {/* End Temperature */} + + + ); +} diff --git a/sol-ink/src/components/Sun.tsx b/sol-ink/src/components/Sun.tsx new file mode 100644 index 0000000..2395523 --- /dev/null +++ b/sol-ink/src/components/Sun.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Box, Text } from 'ink'; + +interface SunProps { + stage?: number; // 0-3 for different sun appearances + size?: 'small' | 'medium' | 'large'; +} + +// Sun ASCII art with rays - warm and inviting +const SUN_ART = { + small: [ + ' \\ | / ', + ' -- (\\*/) -- ', + ' / | \\ ', + ], + medium: [ + ' \\ | / ', + ' \\ | / ', + ' \\ | / ', + ' ------(@)------ ', + ' / | \\ ', + ' / | \\ ', + ' / | \\ ', + ], + large: [ + ' \\ | / ', + ' \\ | / ', + ' \\ | / ', + ' \\ | / ', + ' --------(@)-------- ', + ' / | \\ ', + ' / | \\ ', + ' / | \\ ', + ' / | \\ ', + ], +}; + +// Colors based on stage (sunrise progression) - warm gradient +const STAGE_COLORS = [ + { sun: '#8B0000', rays: '#4a0000', glow: '#2a0000' }, // Deep red - pre-dawn + { sun: '#FF4500', rays: '#CC3700', glow: '#FF6347' }, // Orange-red - early + { sun: '#FFA500', rays: '#CC8400', glow: '#FFD700' }, // Orange - mid + { sun: '#FFD700', rays: '#CCAC00', glow: '#FFFF00' }, // Golden - bright +]; + +export default function Sun({ stage = 2, size = 'medium' }: SunProps) { + const art = SUN_ART[size]; + const colors = STAGE_COLORS[Math.min(stage, STAGE_COLORS.length - 1)]; + + return ( + + {art.map((line, i) => { + // Determine if this line is the sun center or rays + const isCenterLine = line.includes('(@)') || line.includes('(*)'); + + return ( + + {line} + + ); + })} + + ); +} + +// Animated sun with glow effect +export function AnimatedSun({ frame }: { frame: number }) { + // Cycle through stages based on frame + const stage = Math.min(Math.floor(frame / 15), 3); + const colors = STAGE_COLORS[stage]; + + // Pulsing glow effect + const glowIntensity = Math.sin(frame * 0.15) * 0.5 + 0.5; + const useGlow = glowIntensity > 0.6; + + const art = [ + ' \\ | / ', + ' \\ | / ', + ' \\ | / ', + ` ------${useGlow ? '(*)' : '(@)'}------ `, + ' / | \\ ', + ' / | \\ ', + ' / | \\ ', + ]; + + return ( + + {art.map((line, i) => { + const isCenterLine = line.includes('(@)') || line.includes('(*)'); + const color = isCenterLine + ? (useGlow ? colors.glow : colors.sun) + : colors.rays; + + return ( + + {line} + + ); + })} + + ); +} diff --git a/sol-ink/src/hooks/useLampConnection.ts b/sol-ink/src/hooks/useLampConnection.ts new file mode 100644 index 0000000..75cbcf5 --- /dev/null +++ b/sol-ink/src/hooks/useLampConnection.ts @@ -0,0 +1,87 @@ +import { useState, useCallback } from 'react'; +import { exec } from 'child_process'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +interface LampConnectionState { + connected: boolean; + checking: boolean; + error: string | null; +} + +interface UseLampConnection extends LampConnectionState { + checkConnection: () => void; +} + +// Default bulb IP from main.py +const DEFAULT_BULB_IP = '192.168.1.77'; + +export function useLampConnection(): UseLampConnection { + const [state, setState] = useState({ + connected: false, + checking: false, + error: null, + }); + + const checkConnection = useCallback(() => { + setState(prev => ({ ...prev, checking: true, error: null })); + + // Get path to parent directory where Python script lives + const __dirname = dirname(fileURLToPath(import.meta.url)); + const lampDir = resolve(__dirname, '../../..'); + + // Use Python script to discover/check lamp + const cmd = `cd "${lampDir}" && uv run python -c " +import asyncio +from kasa import Device + +async def check(): + try: + bulb = await Device.connect(host='${DEFAULT_BULB_IP}') + await bulb.update() + print('connected' if bulb else 'not found') + except Exception as e: + print(f'error:{e}') + +asyncio.run(check()) +"`; + + exec(cmd, { timeout: 10000 }, (error, stdout, stderr) => { + if (error) { + setState({ + connected: false, + checking: false, + error: 'Connection failed', + }); + return; + } + + const output = stdout.trim().toLowerCase(); + + if (output === 'connected') { + setState({ + connected: true, + checking: false, + error: null, + }); + } else if (output.startsWith('error:')) { + setState({ + connected: false, + checking: false, + error: output.replace('error:', '').substring(0, 30), + }); + } else { + setState({ + connected: false, + checking: false, + error: 'Lamp not found', + }); + } + }); + }, []); + + return { + ...state, + checkConnection, + }; +} diff --git a/sol-ink/src/index.tsx b/sol-ink/src/index.tsx new file mode 100644 index 0000000..7de9430 --- /dev/null +++ b/sol-ink/src/index.tsx @@ -0,0 +1,16 @@ +#!/usr/bin/env bun +import React from 'react'; +import { render } from 'ink'; +import App from './App.js'; + +// Clear screen and hide cursor for cleaner UI +process.stdout.write('\x1B[?25l'); // Hide cursor +process.stdout.write('\x1B[2J\x1B[H'); // Clear screen + +const { waitUntilExit } = render(); + +waitUntilExit().then(() => { + process.stdout.write('\x1B[?25h'); // Show cursor + process.stdout.write('\x1B[2J\x1B[H'); // Clear screen + process.exit(0); +}); diff --git a/sol-ink/tsconfig.json b/sol-ink/tsconfig.json new file mode 100644 index 0000000..3224ed2 --- /dev/null +++ b/sol-ink/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/sol_tui.py b/sol_tui.py index 73befc6..3630c31 100644 --- a/sol_tui.py +++ b/sol_tui.py @@ -211,9 +211,9 @@ async def run_animation(self) -> None: # Messages msg1 = "Let there be light." - msg2 = "Good Morning, welcome to the game!" + msg2 = "Welcome to the game." now = datetime.now() - msg3 = f"\u2600 {now.strftime('%A, %B %d')} \u2022 {now.strftime('%H:%M')}" + msg3 = f"{now.strftime('%A, %B %d')} {now.strftime('%H:%M')}" # Get terminal size width = self.app.size.width @@ -228,9 +228,9 @@ async def run_animation(self) -> None: end_pos = sun_final_row total_frames = start_pos - end_pos - border_color = "bright_yellow" + border_color = "yellow" sun_color = "bright_yellow" - sky_bg = "grey27" + sky_bg = "grey19" revealed = [False, False, False] @@ -248,15 +248,19 @@ async def run_animation(self) -> None: # Build the frame lines = [] - # Top border - lines.append(("\u2588" * width, border_color)) + # Top border (box drawing) + top_border = Text() + top_border.append("\u250c", style=border_color) + top_border.append("\u2500" * (width - 2), style=border_color) + top_border.append("\u2510", style=border_color) + lines.append(top_border) for row in range(1, height - 1): is_sun_row = sun_row <= row < sun_row + sun_height sun_idx = row - sun_row line_text = Text() - line_text.append("\u2588", style=border_color) + line_text.append("\u2502", style=border_color) if is_sun_row and 0 <= sun_idx < sun_height: sun_line = sun_art[sun_idx] @@ -286,11 +290,15 @@ async def run_animation(self) -> None: else: line_text.append(" " * (width - 2), style=f"on {sky_bg}") - line_text.append("\u2588", style=border_color) + line_text.append("\u2502", style=border_color) lines.append(line_text) - # Bottom border - lines.append(Text("\u2588" * width, style=border_color)) + # Bottom border (box drawing) + bottom_border = Text() + bottom_border.append("\u2514", style=border_color) + bottom_border.append("\u2500" * (width - 2), style=border_color) + bottom_border.append("\u2518", style=border_color) + lines.append(bottom_border) # Combine all lines full_text = Text() From dda07f667dc7997fbafe550eba291e18200d5a8a Mon Sep 17 00:00:00 2001 From: JackSwitzer Date: Mon, 19 Jan 2026 14:42:55 -0500 Subject: [PATCH 6/6] Animation auto-returns to menu for 'A' key - 'A' key: plays animation, auto-returns to main menu after 1s - Enter key: plays animation, waits for dismiss, then starts alarm - Added auto_return parameter to AnimationScreen Co-Authored-By: Claude Opus 4.5 --- sol_tui.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/sol_tui.py b/sol_tui.py index 3630c31..30a7917 100644 --- a/sol_tui.py +++ b/sol_tui.py @@ -178,10 +178,10 @@ class AnimationScreen(Screen): Binding("q", "dismiss", "Exit"), ] - def __init__(self) -> None: + def __init__(self, auto_return: bool = False) -> None: super().__init__() self._escape_count = 0 - self._escape_timer = None + self._auto_return = auto_return def compose(self) -> ComposeResult: yield Static(id="animation-canvas") @@ -313,6 +313,12 @@ async def run_animation(self) -> None: canvas.update(full_text) await asyncio.sleep(0.08) + # After animation completes + if self._auto_return: + # Brief pause then auto-return to main menu + await asyncio.sleep(1.0) + self.dismiss() + class SolApp(App): """Sol Sunrise Alarm TUI.""" @@ -559,8 +565,8 @@ def _adjust_wake_time(self, delta_minutes: int) -> None: pass def action_animate(self) -> None: - """Show the sunrise animation.""" - self.push_screen(AnimationScreen()) + """Show the sunrise animation (auto-returns to menu).""" + self.push_screen(AnimationScreen(auto_return=True)) def action_confirm(self) -> None: """Confirm settings and start the alarm."""