From 6af14bd730cad185ef73c3577971d5d41e29fc89 Mon Sep 17 00:00:00 2001 From: Harshit Ruwali Date: Sat, 30 May 2026 13:04:30 +0530 Subject: [PATCH 1/2] refactor(upload): create shared upload helper to streamline upload process --- Makefile | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index bd3f725..ad7dd77 100644 --- a/Makefile +++ b/Makefile @@ -70,15 +70,29 @@ flash: build upload ## If the gochi daemon is running it is holding the ## serial port; we ask it to release the port before the ## upload and reacquire on exit (success or failure). + +# Shared upload helper — port guard + daemon-pause + upload. +# Usage: $(call do-upload, INPUT_DIR, SKETCH_PATH) +define do-upload +@if [ -z "$(PORT)" ]; then \ + echo "No port detected. Specify one explicitly, e.g.:"; \ + echo " make $@ PORT=/dev/cu.usbmodemXXXX (macOS)"; \ + echo " make $@ PORT=/dev/ttyACM0 (Linux / WSL)"; \ + echo " make $@ PORT=COM7 (Windows; from Git Bash)"; \ + exit 1; \ +fi; \ +_paused=0; \ +if command -v gochi >/dev/null 2>&1 && launchctl list com.tamagotchi.daemon >/dev/null 2>&1; then \ + _paused=1; \ + echo "→ gochi stop (releasing serial port)"; \ + gochi stop >/dev/null; \ +fi; \ +trap '[ "$$_paused" = 1 ] && echo "→ gochi start (reacquiring)" && gochi start >/dev/null' EXIT; \ +$(ARDUINO) upload --fqbn $(FQBN) --port "$(PORT)" --input-dir $(1) $(2) +endef + upload: - @_paused=0; \ - if command -v gochi >/dev/null 2>&1 && launchctl list com.tamagotchi.daemon >/dev/null 2>&1; then \ - _paused=1; \ - echo "→ gochi stop (releasing serial port)"; \ - gochi stop >/dev/null; \ - fi; \ - trap '[ "$$_paused" = 1 ] && echo "→ gochi start (reacquiring)" && gochi start >/dev/null' EXIT; \ - $(ARDUINO) upload --fqbn $(FQBN) --port $(PORT) --input-dir $(BUILD) $(SKETCH) + $(call do-upload,$(BUILD),$(SKETCH)) ## erase — wipe the entire flash (factory reset). ## arduino-cli has no built-in erase, so we shell out @@ -144,17 +158,17 @@ clean: ## test-led — compile + flash the LED-blink bring-up test test-led: $(ARDUINO) compile --fqbn $(FQBN) $(BUILD_PROPS) --build-path firmware/tests/led/build firmware/tests/led - $(ARDUINO) upload --fqbn $(FQBN) --port $(PORT) --input-dir firmware/tests/led/build firmware/tests/led + $(call do-upload,firmware/tests/led/build,firmware/tests/led) ## test-oled — compile + flash the OLED bring-up test test-oled: $(ARDUINO) compile --fqbn $(FQBN) $(BUILD_PROPS) --build-path firmware/tests/oled/build firmware/tests/oled - $(ARDUINO) upload --fqbn $(FQBN) --port $(PORT) --input-dir firmware/tests/oled/build firmware/tests/oled + $(call do-upload,firmware/tests/oled/build,firmware/tests/oled) ## test-buzzer — compile + flash the buzzer bring-up test test-buzzer: $(ARDUINO) compile --fqbn $(FQBN) $(BUILD_PROPS) --build-path firmware/tests/buzzer/build firmware/tests/buzzer - $(ARDUINO) upload --fqbn $(FQBN) --port $(PORT) --input-dir firmware/tests/buzzer/build firmware/tests/buzzer + $(call do-upload,firmware/tests/buzzer/build,firmware/tests/buzzer) ## test-mpu — compile + flash the MPU-6050 streaming test, then ## open the live viewer in the default browser. Needs @@ -162,5 +176,5 @@ test-buzzer: ## holding the port, `gochi stop` first. test-mpu: $(ARDUINO) compile --fqbn $(FQBN) $(BUILD_PROPS) --build-path firmware/tests/mpu/build firmware/tests/mpu - $(ARDUINO) upload --fqbn $(FQBN) --port $(PORT) --input-dir firmware/tests/mpu/build firmware/tests/mpu + $(call do-upload,firmware/tests/mpu/build,firmware/tests/mpu) @$(OPEN) firmware/tests/mpu/visualize.html From 8f8edaa64cc51e638d71bfd4f75054911fde5803 Mon Sep 17 00:00:00 2001 From: Harshit Ruwali Date: Sat, 27 Jun 2026 01:12:31 +0530 Subject: [PATCH 2/2] feat: add Gochi Activity Watcher VS Code extension with auto-status updates based on user activity --- .gitignore | 2 + BLE-SETUP.md | 259 +++ README.md | 96 + cli/README.md | 236 +++ cli/package-lock.json | 2201 ++++++++++++++++++++++ cli/package.json | 1 + cli/src/cli.ts | 113 ++ cli/src/client.ts | 10 + cli/src/daemon.ts | 122 +- cli/src/image.ts | 41 + cli/src/spotify.ts | 432 +++++ cli/src/status.ts | 99 + cli/src/transport_ble.ts | 192 ++ cli/src/transport_interface.ts | 12 + cli/test-ble.js | 31 + cli/test-noble.js | 20 + firmware/firmware.ino | 44 +- firmware/src/modes/free_mode.cpp | 2 +- firmware/src/transport.cpp | 13 +- firmware/src/transport.h | 7 + firmware/src/transport_ble.cpp | 126 ++ firmware/src/transport_ble.h | 98 + firmware/src/transport_multi.h | 27 + package.json | 1 + vscode-extension/out/extension.js | 211 +++ vscode-extension/out/extension.js.map | 1 + vscode-extension/out/gochi-client.js | 94 + vscode-extension/out/gochi-client.js.map | 1 + vscode-extension/out/watcher.js | 318 ++++ vscode-extension/out/watcher.js.map | 1 + vscode-extension/package-lock.json | 58 + vscode-extension/package.json | 76 + vscode-extension/src/extension.ts | 216 +++ vscode-extension/src/gochi-client.ts | 98 + vscode-extension/src/watcher.ts | 322 ++++ vscode-extension/tsconfig.json | 14 + web-ble-controller.html | 467 +++++ 37 files changed, 6052 insertions(+), 10 deletions(-) create mode 100644 BLE-SETUP.md create mode 100644 cli/package-lock.json create mode 100644 cli/src/spotify.ts create mode 100644 cli/src/status.ts create mode 100644 cli/src/transport_ble.ts create mode 100644 cli/src/transport_interface.ts create mode 100644 cli/test-ble.js create mode 100644 cli/test-noble.js create mode 100644 firmware/src/transport_ble.cpp create mode 100644 firmware/src/transport_ble.h create mode 100644 firmware/src/transport_multi.h create mode 100644 package.json create mode 100644 vscode-extension/out/extension.js create mode 100644 vscode-extension/out/extension.js.map create mode 100644 vscode-extension/out/gochi-client.js create mode 100644 vscode-extension/out/gochi-client.js.map create mode 100644 vscode-extension/out/watcher.js create mode 100644 vscode-extension/out/watcher.js.map create mode 100644 vscode-extension/package-lock.json create mode 100644 vscode-extension/package.json create mode 100644 vscode-extension/src/extension.ts create mode 100644 vscode-extension/src/gochi-client.ts create mode 100644 vscode-extension/src/watcher.ts create mode 100644 vscode-extension/tsconfig.json create mode 100644 web-ble-controller.html diff --git a/.gitignore b/.gitignore index ed5b676..e1b9719 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ firmware/tests/*/build/ .env .DS_Store + +node_modules/ \ No newline at end of file diff --git a/BLE-SETUP.md b/BLE-SETUP.md new file mode 100644 index 0000000..d20d2aa --- /dev/null +++ b/BLE-SETUP.md @@ -0,0 +1,259 @@ +# Bluetooth LE Setup Guide + +Your Gochi device now supports wireless communication via Bluetooth Low Energy (BLE)! + +## Firmware Setup + +### Enable BLE in Firmware + +The firmware supports both USB Serial and BLE simultaneously. BLE is enabled by default. + +In `firmware/firmware.ino`, ensure this line is set to 1: +```cpp +#define BLE_ENABLED 1 +``` + +### How it Works + +When BLE is enabled: +- The device advertises as `Gochi-XXXX` (where XXXX are the last 4 hex digits of the MAC address) +- Both USB Serial and BLE transports work simultaneously +- Commands received from either transport get responses sent to both + +### Flash the Firmware + +```bash +make flash +``` + +## Using BLE to Control Your Device + +You have two excellent options for wireless control: + +### Option 1: Web Browser Controller (Recommended) + +The easiest way to control your Gochi device is through the included web page: + +1. **Open the web controller:** + ```bash + open web-ble-controller.html + ``` + +2. **Click "Connect to Gochi"** - Your browser will scan for devices + +3. **Select your device** from the list (e.g., "Gochi-EE74") + +4. **Start controlling!** + - Switch faces with one click + - Set moods + - Send custom text + - Run commands like PING, GET state + - See all device responses in real-time + +**Requirements:** +- Chrome, Edge, or any Chromium-based browser +- macOS, Windows, Linux, or Android (iOS Safari doesn't support Web Bluetooth) + +### Option 2: Mobile Apps + +Use professional BLE debugging apps on your phone or tablet: + +### Option 2: Mobile Apps + +Use professional BLE debugging apps on your phone or tablet: + +#### nRF Connect (iOS/Android) +1. Download "nRF Connect" from the App Store or Google Play +2. Scan for "Gochi-XXXX" +3. Connect to the device +4. Find the Nordic UART Service +5. Enable notifications on the TX characteristic (to see responses) +6. Write commands to the RX characteristic: + - Write as UTF-8 text + - Include newline: `SHOW face happy\n` +7. Responses appear in TX notifications + +#### LightBlue (iOS/macOS) +1. Download "LightBlue" from the App Store +2. Scan and connect to "Gochi-XXXX" +3. Find the Nordic UART Service +4. Subscribe to TX characteristic for responses +5. Write UTF-8 strings to RX characteristic with newline + +## Troubleshooting + +### Web controller can't find device + +Make sure: +- You're using Chrome, Edge, or a Chromium-based browser +- Bluetooth is enabled on your computer +- Your Gochi device is powered on and nearby +- The firmware has been flashed with BLE enabled (`BLE_ENABLED 1`) + +### "No devices found" when scanning + +- Power cycle the Gochi device +- Check that the firmware compiled successfully with BLE support +- Try moving closer to the device (BLE range is ~10 meters) +- Refresh the web page and try again + +### Connection fails or drops + +- Make sure no other device is connected to the Gochi +- Close other BLE applications +- Restart Bluetooth on your computer/phone +- Power cycle the Gochi device + +### macOS Bluetooth Permissions + +On macOS, you may need to grant Bluetooth permissions to your browser in: + +System Settings → Privacy & Security → Bluetooth + +## Available Commands + +Send these commands via the web controller or mobile apps: + +### Display Commands +- `SHOW face ` - Switch expressions (neutral, happy, sad, sleepy, excited, surprised, angry, love, shy) +- `SHOW text ` - Display scrolling text +- `SET mood ` - Set mood (content, playful, grumpy, sleepy, affectionate) + +### Query Commands +- `PING` - Check connection (returns "PONG") +- `GET state` - Get current view and expression +- `GET fps` - Get display frame rate +- `LIST faces` - List all available expressions +- `SCAN i2c` - Scan I2C buses for devices + +## Advanced Features: Spotify & Status + +### Spotify "Now Playing" Integration + +Your Gochi can display the currently playing song from Spotify! This feature requires the daemon running on your laptop/desktop, but once set up, your Gochi automatically updates with song info. + +**How it works:** +1. The daemon polls Spotify's API and caches the current song info +2. It automatically displays "Artist - Song Name" on your Gochi +3. The daemon handles OAuth tokens, image conversion, etc. +4. Updates happen every 5 seconds while Spotify is playing + +**Initial Setup (One-time, from laptop/desktop):** + +First, get a Spotify Client ID: +1. Go to https://developer.spotify.com/dashboard +2. Create an app (name it "Gochi" or whatever you like) +3. Copy the Client ID +4. In "Edit Settings", add `http://127.0.0.1:8765/callback` to "Redirect URIs" +5. Click "Save" + +Then set up the CLI: +```bash +# Make sure daemon is running +gochi setup + +# Login to Spotify (opens browser for OAuth) +gochi spotify login YOUR_SPOTIFY_CLIENT_ID + +# Start watching Spotify (this runs continuously) +gochi spotify watch +``` + +The `spotify watch` command will: +- Poll Spotify every 5 seconds +- Display "Artist - Song Name" as scrolling text on your Gochi +- Continue until you press Ctrl-C + +**Alternative: One-shot updates** + +Instead of continuous watching, you can manually trigger updates: +```bash +gochi spotify now +``` + +This displays the current song once and exits. + +**While Spotify is running:** + +Your Gochi will automatically update with song info. You can still use BLE to override the display temporarily: + +``` +SHOW face happy\n +``` + +This overrides the Spotify display until the next poll (5 seconds). + +**Pro tip:** Keep `gochi spotify watch` running in a terminal on your laptop, and use BLE from your phone to change faces/status throughout the day! + +### Office Status Profiles + +Set your availability status! Perfect for displaying your work state to colleagues. + +**Note:** Status profiles are a daemon-level convenience. When using BLE directly, you need to send the individual commands. The web controller handles this automatically! + +**Via Web Controller:** +Just click the status button! The web page sends the right commands automatically. + +**Via BLE directly (LightBlue app):** +Send the commands in sequence. For example, for "In Meeting" status: + +1. Write to RX: `SET mood content\n` +2. Write to RX: `SHOW text In Meeting\n` + +**Status Profile Commands:** + +| Status | Commands to Send | +|--------|------------------| +| **Available** | `SET mood content` → `SHOW face happy` | +| **Busy** | `SET mood grumpy` → `SHOW face neutral` | +| **In Meeting** | `SET mood content` → `SHOW text In Meeting` | +| **Deep Focus** | `SET mood sleepy` → `SHOW text Deep Focus` | +| **On Break** | `SET mood playful` → `SHOW text On Break!` | +| **Away** | `SET mood sleepy` → `SHOW text Away` | +| **Do Not Disturb** | `SET mood grumpy` → `SHOW text DND` | +| **Frustrated** | `SET mood grumpy` → `SHOW face angry` | +| **Reviewing** | `SET mood content` → `SHOW text Reviewing` | +| **Thinking** | `SET mood playful` → `SHOW face surprised` | + +**Example in LightBlue ("In Meeting" status):** +1. Connect to your Gochi device +2. Find the Nordic UART Service +3. Select the RX Characteristic (Write) +4. Write first command: `SET mood content` (as UTF-8 text with `\n`) +5. Write second command: `SHOW text In Meeting` (as UTF-8 text with `\n`) +6. Your device now shows "In Meeting" with content mood + +**Pro Tip:** Use the web controller instead! It handles multi-command sequences automatically with one click. + +## BLE Protocol Details + +The device uses the **Nordic UART Service (NUS)** UUIDs: +- Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` +- RX (Write): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (client → device) +- TX (Notify): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` (device → client) + +This makes the device compatible with any NUS-compatible BLE terminal or app. + +### Protocol Format +- Commands are newline-terminated ASCII strings +- Write to RX characteristic: `\n` +- Responses come via TX characteristic notifications +- All commands follow the same format as the USB Serial protocol + +### Example Command Flow +1. **Connect** to the device's GATT server +2. **Discover** the Nordic UART Service +3. **Subscribe** to TX characteristic for notifications +4. **Write** to RX characteristic: `SHOW face happy\n` +5. **Receive** notification on TX: `OK\n` + +## What's Next? + +Once you have BLE working, you can: + +1. **Build custom apps** - Use the NUS protocol to create your own mobile or web apps +2. **Automate control** - Use Home Assistant, Node-RED, or other automation tools +3. **Extend the firmware** - Add custom BLE characteristics for additional features + +The Web Bluetooth API documentation: https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API + diff --git a/README.md b/README.md index acee7a9..17d4963 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,20 @@ plane reacting to roll / pitch plus live numeric values for all six axes. See [`firmware/tests/README.md`](firmware/tests/README.md) for details. +## Bluetooth LE (Wireless Control) + +The firmware supports **Bluetooth Low Energy** for wireless control! The device advertises as `Gochi-XXXX` and accepts the same commands over BLE that it does over USB Serial. + +**Quick Start:** +1. Flash the firmware with `make flash` (BLE is enabled by default) +2. Open `web-ble-controller.html` in Chrome or Edge +3. Click "Connect to Gochi" and select your device +4. Control wirelessly from your browser! + +**Alternative:** Use mobile apps like **nRF Connect** or **LightBlue** on iOS/Android. + +See [BLE-SETUP.md](BLE-SETUP.md) for complete setup instructions and protocol details. + ## Build-time configuration (`.env`) A few build-time knobs live in a user-local `.env` at the repo root. @@ -97,6 +111,88 @@ make flash PORT=/dev/ttyACM0 # Linux / WSL make flash PORT=COM7 # Windows ``` +## CLI, HTTP API & VS Code extension + +The `cli/` directory contains the Node CLI and daemon that drive the pet over +USB. See [`cli/README.md`](cli/README.md) for full documentation including: + +- `gochi status` — set your availability (busy, in-meeting, deep-focus, …) +- HTTP API (`/status`, `/statuses`, and all device commands) +- VS Code extension (`vscode-extension/`) that auto-updates the pet based on + editor activity — typing, debugging, build results, idle time + +## Availability status + +Set your current availability with one command — useful in an office where +colleagues can glance at your desk: + +```sh +gochi status available # happy face, content mood +gochi status busy # neutral face, grumpy mood +gochi status in-meeting # scrolls "In Meeting" +gochi status deep-focus # scrolls "Deep Focus" +gochi status frustrated # angry face, grumpy mood +gochi status on-break # scrolls "On Break!" +gochi status away # scrolls "Away" +gochi status do-not-disturb # scrolls "DND" +gochi status reviewing # scrolls "Reviewing" +gochi status thinking # surprised face, playful mood + +gochi list statuses # show all presets with descriptions +``` + +## VS Code extension — auto status + project context + +The `vscode-extension/` directory contains a companion extension that watches +your editor activity and **automatically** updates the pet — no manual +`gochi status` calls needed. + +### What it detects + +| Activity | Status shown on pet | +| -------- | ------------------- | +| 45 s of sustained typing | `deep-focus` | +| Debug session active | `thinking` | +| 5+ new errors / build fails | `frustrated` | +| Errors clear / build passes | `available` | +| 5 min no keyboard activity | `away` | + +### Project label + +Every state transition also **overlays a project-aware message** on the display +so colleagues can see both which project you're on and what you're doing: + +``` +Alpha | Deep Focus +Alpha | Thinking... +Alpha | Frustrated +``` + +By default the workspace folder name is used. Override it per-project in +`.vscode/settings.json`: + +```jsonc +// Project Alpha workspace +{ "gochi.projectLabel": "Alpha" } + +// Project Beta workspace +{ "gochi.projectLabel": "Beta" } +``` + +Set `"gochi.projectLabel": ""` to disable the overlay and show the bare face/text +from the status preset instead. + +### Install + +```sh +cd vscode-extension +npm install && npm run compile +# VS Code: Ctrl+Shift+P → Developer: Install Extension from Location… +gochi server enable # HTTP frontend must be running +``` + +See [`cli/README.md`](cli/README.md) for the full extension reference. + ## Board notes - **USB CDC On Boot is enabled** in the FQBN (`CDCOnBoot=cdc`) so `Serial` diff --git a/cli/README.md b/cli/README.md index 47656e6..cd638b4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -65,6 +65,19 @@ gochi face # opens a select with all 12 faces gochi mood playful gochi mood # opens a select with all 5 moods +# availability status — applies a preset (face + mood + optional text) +gochi status # opens a rich picker with descriptions +gochi status available +gochi status busy +gochi status in-meeting +gochi status deep-focus +gochi status frustrated +gochi status on-break +gochi status away +gochi status do-not-disturb +gochi status reviewing +gochi status thinking + # text gochi text hello there # extra args are joined @@ -77,6 +90,7 @@ gochi image ./icon.png --invert --bg white # invert + white letterbox gochi get state gochi get fps gochi list faces +gochi list statuses # all availability presets gochi ping gochi health @@ -93,6 +107,16 @@ gochi test all # run them all in order Faces: `neutral happy sad sleepy excited surprised angry blink love sexy shy dead`. Moods: `content playful grumpy sleepy affectionate`. +Statuses: `available busy in-meeting deep-focus frustrated on-break away do-not-disturb reviewing thinking`. + +### Spotify + +```sh +gochi spotify login # one-time auth (opens a browser) +gochi spotify now # print + display the current track +gochi spotify watch # live polling loop — push every track change +gochi spotify logout # remove stored tokens +``` The CLI talks to the daemon over `~/.tamagotchi/daemon.sock` by default. Set `GOCHI_URL=http://host:port` to point it at a remote daemon's @@ -168,6 +192,8 @@ returns 200 either way** — when offline, the response is | POST | /text | `{"text":"..."}` | `SHOW text …` | | POST | /image | `{"data":""}` | `SHOW image …` (128×64 1bpp, MSB-first) | | POST | /mood | `{"name":"..."}` | `SET mood …` | +| POST | /status | `{"name":"..."}` | `SET mood … + SHOW face/text …` (preset) | +| GET | /statuses | — | list all status presets | | GET | /state | — | `GET state` | | GET | /fps | — | `GET fps` | | GET | /faces | — | `LIST faces` | @@ -178,9 +204,217 @@ Quick check (HTTP frontend must be enabled): ```sh curl http://localhost:7474/health curl -X POST http://localhost:7474/face -H 'content-type: application/json' -d '{"name":"happy"}' +curl -X POST http://localhost:7474/status -H 'content-type: application/json' -d '{"name":"in-meeting"}' +curl http://localhost:7474/statuses curl http://localhost:7474/state ``` +## Spotify integration + +Gochi can scroll the currently playing Spotify track on the OLED display. It +uses **OAuth 2.0 Authorization Code + PKCE** — no client secret needed, and +tokens are stored locally at `~/.tamagotchi/spotify.json` (mode `0600`). + +### 1. Create a Spotify app + +1. Go to [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) and create an app. +2. Under **Edit Settings → Redirect URIs**, add: `http://127.0.0.1:8765/callback` + > Use `127.0.0.1`, **not** `localhost` — Spotify's dashboard accepts `http://` only for the + > loopback IP (`127.0.0.1`), not for the hostname `localhost`. +3. Copy the **Client ID** (you don't need the secret). + +### 2. Login + +```sh +gochi spotify login +``` + +This opens your browser at the Spotify consent page. After you approve, the +callback is captured automatically on `127.0.0.1:8765` and tokens are saved. +You only need to do this once; tokens are refreshed automatically. + +If your browser doesn't redirect back (e.g. a corporate proxy blocks loopback +redirects), the CLI also accepts a manual paste — just copy the full redirect +URL from your browser's address bar and paste it into the terminal when +prompted. + +### 3. Commands + +```sh +gochi spotify now # one-shot: print current track + push to display +gochi spotify watch # live loop — polls every 10 s, pushes on track change +gochi spotify logout # remove stored tokens +``` + +`watch` runs until Ctrl-C. It only sends a command to the device when the +track actually changes, so it's quiet if you're listening to one song for a while. + +**Example output:** + +``` +Watching Spotify… (updates every 10s, Ctrl-C to stop) + +▶ Bohemian Rhapsody - Queen +▶ Stairway to Heaven - Led Zeppelin +⏸ Stairway to Heaven - Led Zeppelin +``` + +### Display format + +The track is shown as `Song Title - Artist` as scrolling text on the OLED. +Long titles scroll across the full 128 px width automatically (firmware handles it). + +### Scopes requested + +| Scope | Why | +| ----- | --- | +| `user-read-currently-playing` | Fetch the active track | +| `user-read-playback-state` | Know if playback is paused | + +No write scopes are ever requested. + +## Availability status + +`gochi status` composes three primitives (`SET mood`, `SHOW face`, `SHOW text`) into +one named preset. Statuses with a short text label show the **text view** so +colleagues can read them at a glance; expressive statuses show the matching +**face expression** instead. + +| Name | Visible display | Face | Mood | +| ----------------- | ---------------- | ---------- | ------------ | +| `available` | happy face | happy | content | +| `busy` | neutral face | neutral | grumpy | +| `in-meeting` | "In Meeting" | neutral | content | +| `deep-focus` | "Deep Focus" | sleepy | sleepy | +| `frustrated` | angry face | angry | grumpy | +| `on-break` | "On Break!" | excited | playful | +| `away` | "Away" | sleepy | sleepy | +| `do-not-disturb` | "DND" | dead | grumpy | +| `reviewing` | "Reviewing" | surprised | content | +| `thinking` | surprised face | surprised | playful | + +Presets are defined in `src/status.ts`. Add or edit entries there; the daemon, +client, and CLI all pick them up at build time. + +The HTTP endpoint applies mood + view atomically (mood first, then the +view switch) and responds: + +```json +{ "ok": true, "connected": true, "status": "in-meeting", "label": "In Meeting" } +``` + +## VS Code extension + +The `vscode-extension/` directory contains a companion extension that +automatically updates the status based on what you're doing in the editor — no +manual `gochi status` calls needed. + +### Install + +```sh +cd vscode-extension +npm install +npm run compile +# VS Code: Ctrl+Shift+P → Developer: Install Extension from Location… +``` + +The HTTP frontend must be enabled (it is by default after `gochi setup`): + +```sh +gochi server enable +``` + +### Auto-detected states + +| State | What triggers it | +| ------------ | ------------------------------------------------------------- | +| `available` | Default; also restored after errors clear or debug ends | +| `deep-focus` | Sustained typing for 45 s with no debug session or errors | +| `thinking` | A debug session is active | +| `frustrated` | 5+ new errors vs baseline, or a build/test task exits non-zero | +| `away` | No keyboard or editor activity for 5 minutes | + +Priority when multiple signals fire: **thinking › frustrated › deep-focus › available › away**. + +### Status bar + +A status-bar item (bottom-right) shows the current state at a glance: + +- `$(smiley) Available` — auto-mode tracking normally +- `$(eye) Deep Focus` — sustained typing detected +- `$(bug) Thinking` — debug session active +- `$(warning) Frustrated` — error spike / build failure +- `$(clock) Away` — idle timeout hit +- `$(lock) Deep Focus (manual, 8m)` — manual override active, N minutes left +- `$(circle-slash) Gochi (paused)` — auto-mode disabled via settings + +Clicking the item opens a quick-pick. If an override is active, the first +option is **Resume auto-mode** (cancels the timer immediately). + +### Commands + +| Command | What it does | +| ------------------------- | ----------------------------------------------------- | +| `Gochi: Set Status` | Pick a status manually; pauses auto-mode for 30 min | +| `Gochi: Toggle Auto Mode` | Enable / disable automatic status tracking | + +### Settings + +All thresholds are configurable under **Settings → Extensions → Gochi Activity Watcher**: + +| Setting | Default | Description | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `gochi.autoMode.enabled` | `true` | Enable / disable auto tracking | +| `gochi.daemonUrl` | `http://localhost:7474` | URL of the Gochi HTTP frontend | +| `gochi.projectLabel` | `""` (workspace folder) | Project name shown as `"Label \| State"` on the OLED display | +| `gochi.autoMode.idleTimeoutMinutes` | `5` | Minutes of inactivity before switching to `away` | +| `gochi.autoMode.focusDelaySeconds` | `45` | Seconds of sustained typing to trigger `deep-focus` | +| `gochi.autoMode.errorThreshold` | `5` | New errors above baseline that trigger `frustrated` | +| `gochi.autoMode.manualOverrideMinutes` | `30` | Minutes auto-mode is paused after a manual status pick | + +#### Project label + +Every auto state transition overlays a `"ProjectName | State"` message on the +display so colleagues can see both which project you're on and what you're doing. + +By default the workspace folder name is used automatically. To override it, +add to your project's `.vscode/settings.json`: + +```jsonc +// Project Alpha +{ "gochi.projectLabel": "Alpha" } + +// Project Beta +{ "gochi.projectLabel": "Beta" } +``` + +Set `"gochi.projectLabel": ""` to disable the overlay entirely — the pet will +show only the bare face/text from the status preset. + +What the display shows after each transition: + +| State | Display (with label "Alpha") | +| ------------ | ----------------------------- | +| `available` | `Alpha \| Available` | +| `deep-focus` | `Alpha \| Deep Focus` | +| `thinking` | `Alpha \| Thinking...` | +| `frustrated` | `Alpha \| Frustrated` | +| `away` | `Alpha \| Away` | + +Manual picks via the command palette also overlay the project label, so +`Gochi: Set Status → In Meeting` shows `Alpha | In Meeting`. + +### Manual override + +Setting a status manually (via the command palette or `gochi status` in the +terminal) activates a **manual override** that suppresses auto-transitions for +`manualOverrideMinutes` minutes. While active: + +- The status bar shows a lock icon and the remaining time. +- The `Set Status` picker offers a **Resume Auto** shortcut. +- The toast notification after setting a status also has a **Resume Auto** button. +- Calling `gochi kill` (to reload daemon code) does not clear the override. + ## How it finds the pet The daemon polls `SerialPort.list()` every ~1.5 s, filters to @@ -208,6 +442,8 @@ cli/ windows.ts Task Scheduler backend src/client.ts CLI's transport (UDS by default, TCP if GOCHI_URL set) src/image.ts PNG/JPG → 128×64 1bpp (dither + MSB-pack) + src/status.ts availability status presets (name → face + mood + text) + src/spotify.ts Spotify OAuth PKCE + now-playing polling ``` ## Notes diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..c3dd540 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,2201 @@ +{ + "name": "@0xpv/gochi", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@0xpv/gochi", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.4.3", + "@serialport/parser-readline": "^12.0.0", + "commander": "^12.1.0", + "node-ble": "^1.8.1", + "serialport": "^12.0.0", + "sharp": "0.34.5", + "tsx": "^4.22.3" + }, + "bin": { + "gochi": "bin/gochi.js" + }, + "devDependencies": { + "@types/node": "^25.9.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.6", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.6", + "@inquirer/core": "^11.2.0", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.0", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.0", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.6", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^4.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.0", + "@inquirer/external-editor": "^3.0.1", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.6", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.6", + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.0", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.0", + "@inquirer/confirm": "^6.1.0", + "@inquirer/editor": "^5.2.0", + "@inquirer/expand": "^5.1.0", + "@inquirer/input": "^5.1.0", + "@inquirer/number": "^4.1.0", + "@inquirer/password": "^5.1.0", + "@inquirer/rawlist": "^5.3.0", + "@inquirer/search": "^4.2.0", + "@inquirer/select": "^5.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.0", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.0", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.6", + "@inquirer/core": "^11.2.0", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.6", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@nornagon/put": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz", + "integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==", + "license": "MIT/X11", + "engines": { + "node": ">=0.3.0" + } + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "12.0.1", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "11.0.0", + "debug": "4.3.4", + "node-addon-api": "7.0.0", + "node-gyp-build": "4.6.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "11.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline/node_modules/@serialport/parser-delimiter": { + "version": "11.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "12.0.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "12.0.0", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.3.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@types/node": { + "version": "25.9.1", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT", + "optional": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/chardet": { + "version": "2.1.1", + "license": "MIT" + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT", + "optional": true + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dbus-next": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.10.2.tgz", + "integrity": "sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==", + "license": "MIT", + "dependencies": { + "@nornagon/put": "0.0.8", + "event-stream": "3.3.4", + "hexy": "^0.2.10", + "jsbi": "^2.0.5", + "long": "^4.0.0", + "safe-buffer": "^5.1.1", + "xml2js": "^0.4.17" + }, + "optionalDependencies": { + "usocket": "^0.3.0" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "optional": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "optional": true + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hexy": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", + "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==", + "license": "MIT", + "bin": { + "hexy": "bin/hexy_cmd.js" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", + "optional": true + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT", + "optional": true + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", + "optional": true + }, + "node_modules/jsbi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz", + "integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ==" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "optional": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "4.0.0", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-addon-api": { + "version": "7.0.0", + "license": "MIT" + }, + "node_modules/node-ble": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/node-ble/-/node-ble-1.13.0.tgz", + "integrity": "sha512-jg8GmyZwowUcze6t/GEEunYvAcASR9hMUpRxs2jehmgdalDQ0xXtkmDfdAQO4Rq3b1gBxBRo43E/ASzbPf3d/A==", + "license": "MIT", + "dependencies": { + "dbus-next": "^0.10.2" + }, + "funding": { + "url": "https://github.com/sponsors/chrvadala" + } + }, + "node_modules/node-gyp-build": { + "version": "4.6.0", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT", + "optional": true + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialport": { + "version": "12.0.0", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "12.0.1", + "@serialport/parser-byte-length": "12.0.0", + "@serialport/parser-cctalk": "12.0.0", + "@serialport/parser-delimiter": "12.0.0", + "@serialport/parser-inter-byte-timeout": "12.0.0", + "@serialport/parser-packet-length": "12.0.0", + "@serialport/parser-readline": "12.0.0", + "@serialport/parser-ready": "12.0.0", + "@serialport/parser-regex": "12.0.0", + "@serialport/parser-slip-encoder": "12.0.0", + "@serialport/parser-spacepacket": "12.0.0", + "@serialport/stream": "12.0.0", + "debug": "4.3.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/sharp": { + "version": "0.34.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", + "optional": true + }, + "node_modules/undici-types": { + "version": "7.24.6", + "devOptional": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/usocket": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/usocket/-/usocket-0.3.0.tgz", + "integrity": "sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ==", + "hasInstallScript": true, + "license": "ISC", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.14.2", + "node-gyp": "^7.1.2" + } + }, + "node_modules/usocket/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/usocket/node_modules/aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "license": "ISC", + "optional": true + }, + "node_modules/usocket/node_modules/are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "node_modules/usocket/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/usocket/node_modules/gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "node_modules/usocket/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/usocket/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "optional": true, + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/usocket/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/usocket/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/usocket/node_modules/node-gyp": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz", + "integrity": "sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.3", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "request": "^2.88.2", + "rimraf": "^3.0.2", + "semver": "^7.3.2", + "tar": "^6.0.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/usocket/node_modules/npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "node_modules/usocket/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/usocket/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT", + "optional": true + }, + "node_modules/usocket/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/usocket/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/usocket/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "optional": true, + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/usocket/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/usocket/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + } + } +} diff --git a/cli/package.json b/cli/package.json index d66660e..513920b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -39,6 +39,7 @@ "server": "tsx src/cli.ts server run" }, "dependencies": { + "node-ble": "^1.8.1", "@inquirer/prompts": "^8.4.3", "@serialport/parser-readline": "^12.0.0", "commander": "^12.1.0", diff --git a/cli/src/cli.ts b/cli/src/cli.ts index fe0bb23..4ab66d3 100755 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -23,7 +23,10 @@ import { runDaemon } from "./daemon"; import { fileToFrameBase64 } from "./image"; import { runServer } from "./server"; import * as service from "./service"; +import { spotifyLogin, spotifyLogout, spotifyNow, spotifyWatch } from "./spotify"; +import { STATUS_PROFILES } from "./status"; import { runTest } from "./test"; +import { discoverDevices } from "./transport_ble"; const VERSION: string = packageJson.version; @@ -103,6 +106,24 @@ program print(await client.mood(chosen)); }); +program + .command("status") + .description("set your availability status (office-friendly presets)") + .argument("[name]", "status name (e.g. available, busy, in-meeting, deep-focus)") + .action(async (name?: string) => { + const chosen = + name ?? + (await select({ + message: "Set your status:", + choices: STATUS_PROFILES.map((p) => ({ + name: p.label, + value: p.name, + description: p.description, + })), + })); + print(await client.status(chosen)); + }); + program .command("text") .description("show a line of text (long text scrolls)") @@ -176,6 +197,15 @@ list .command("faces") .description("list all expression names") .action(async () => print(await client.faces())); +list + .command("statuses") + .description("list all availability status presets") + .action(() => { + const nameWidth = Math.max(...STATUS_PROFILES.map((p) => p.name.length)) + 2; + for (const p of STATUS_PROFILES) { + console.log(` ${p.name.padEnd(nameWidth)} ${p.description}`); + } + }); program .command("ping") @@ -248,6 +278,39 @@ program // --- Self-test --------------------------------------------------------- +// --- Spotify ----------------------------------------------------------- + +const spotify = program + .command("spotify") + .description("connect Spotify and show now-playing on the display"); + +spotify + .command("login") + .description("authenticate with Spotify (opens a browser, stores tokens)") + .argument("", "your Spotify app Client ID from developer.spotify.com") + .action(async (clientId: string) => { + await spotifyLogin(clientId); + }); + +spotify + .command("logout") + .description("remove stored Spotify tokens") + .action(() => spotifyLogout()); + +spotify + .command("now") + .description("show the currently playing track on the display (one-shot)") + .action(async () => { + await spotifyNow(); + }); + +spotify + .command("watch") + .description("poll Spotify and push every track change to the display (Ctrl-C to stop)") + .action(async () => { + await spotifyWatch(); + }); + program .command("test") .description("interactive hardware self-test (asks y/n per component)") @@ -264,6 +327,56 @@ program .description("daemon + device status") .action(async () => print(await client.health())); +// --- Bluetooth LE ------------------------------------------------------ + +const ble = program + .command("ble") + .description("Bluetooth LE device management"); + +ble + .command("scan") + .description("scan for nearby Gochi devices over BLE") + .option("-t, --timeout ", "scan timeout in milliseconds", "5000") + .action(async (opts: { timeout: string }) => { + const timeout = Number(opts.timeout); + if (!Number.isFinite(timeout) || timeout < 0) { + console.error("timeout must be a positive number"); + process.exit(1); + } + + console.log(`Scanning for Gochi devices (${timeout}ms)...`); + try { + const devices = await discoverDevices(timeout); + if (devices.length === 0) { + console.log("No devices found."); + console.log("\nMake sure:"); + console.log(" - Your Gochi device is powered on"); + console.log(" - BLE is enabled in the firmware (BLE_ENABLED 1)"); + console.log(" - Bluetooth is enabled on this computer"); + } else { + console.log(`\nFound ${devices.length} device(s):\n`); + for (const dev of devices) { + console.log(` ${dev.name}`); + console.log(` Address: ${dev.address}`); + } + console.log("\nTo connect via BLE, use: gochi ble connect "); + } + } catch (e: any) { + console.error(`BLE scan failed: ${e?.message || e}`); + process.exit(1); + } + }); + +ble + .command("connect") + .description("connect to a specific Gochi device via BLE") + .argument("", "device name (e.g., Gochi-A1B2) or address") + .action(async (device: string) => { + console.log(`Connecting to ${device} via BLE...`); + const result = await client.bleConnect(device); + print(result); + }); + // --- Daemon management ------------------------------------------------- const daemon = program diff --git a/cli/src/client.ts b/cli/src/client.ts index 8b6ccbc..26c22d0 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -131,3 +131,13 @@ export const ping = () => call("POST", "/ping"); export const i2c = () => call("GET", "/i2c"); export const stop = () => call("POST", "/stop"); export const start = () => call("POST", "/start"); +// Availability status — applies a named profile (face + mood + optional text). +export const status = (name: string) => call("POST", "/status", { name }); +export const statuses = () => call("GET", "/statuses"); +// Spotify "now playing" state stored in the daemon so the VS Code extension +// heartbeat can defer to it instead of overwriting the track with project text. +export const setSpotifyTrack = (track: string | null, image?: string | null) => + call("POST", "/spotify/track", { track: track ?? null, image: image ?? null }); +export const getSpotifyTrack = () => call("GET", "/spotify/track"); +// BLE device connection — tells the daemon to connect to a specific BLE device. +export const bleConnect = (device: string) => call("POST", "/ble/connect", { device }); diff --git a/cli/src/daemon.ts b/cli/src/daemon.ts index 5721ee7..6948599 100644 --- a/cli/src/daemon.ts +++ b/cli/src/daemon.ts @@ -20,18 +20,22 @@ import { clearStaleSocket, ensureDaemonDir, } from "./ipc"; +import { findStatus, STATUS_PROFILES } from "./status"; import { SerialTransport } from "./transport"; +import { BLETransport } from "./transport_ble"; +import type { ITransport } from "./transport_interface"; export const DAEMON_VERSION = "0.1.0"; -// Device wraps the current SerialTransport (if any) and listens to the -// discovery watcher for attach/detach. Only one port is connected at a -// time — first-attached wins. Multi-device support is a future thing. +// Device wraps the current transport (Serial or BLE) and listens to the +// discovery watcher for attach/detach. Only one device is connected at a +// time — first-attached wins for Serial, or explicit BLE connection. class Device { - private transport: SerialTransport | null = null; + private transport: ITransport | null = null; private path: string | null = null; private connecting = false; - // When stopped, the daemon releases the serial port and refuses to + private transportType: "serial" | "ble" = "serial"; + // When stopped, the daemon releases the port and refuses to // open any new ones. Used for arduino-cli flashing — see // `gochi stop` and the firmware Makefile. private stopped = false; @@ -77,6 +81,7 @@ class Device { async attach(path: string): Promise { if (this.stopped || this.connecting || this.isConnected()) return; this.connecting = true; + this.transportType = "serial"; try { const t = new SerialTransport(path); await t.open(); @@ -88,7 +93,7 @@ class Device { t.on("error", (e: Error) => log("serial error:", e.message)); this.transport = t; this.path = path; - log(`connected to ${path}`); + log(`connected to ${path} (USB Serial)`); } catch (e: any) { log(`attach failed for ${path}:`, e?.message || e); this.transport = null; @@ -98,6 +103,45 @@ class Device { } } + async connectBLE(deviceId: string): Promise<{ ok: boolean; message: string }> { + if (this.connecting) { + return { ok: false, message: "already connecting" }; + } + + // Disconnect from current device if any. + if (this.isConnected()) { + log(`disconnecting from current ${this.transportType} device`); + await this.transport?.close(); + this.transport = null; + this.path = null; + } + + this.connecting = true; + this.transportType = "ble"; + try { + const t = new BLETransport(deviceId); + await t.open(); + t.on("close", () => { + log(`BLE disconnected (${deviceId})`); + this.transport = null; + this.path = null; + }); + t.on("error", (e: Error) => log("BLE error:", e.message)); + this.transport = t; + this.path = deviceId; + log(`connected to ${deviceId} (BLE)`); + return { ok: true, message: `Connected to ${deviceId} via BLE` }; + } catch (e: any) { + const msg = e?.message || String(e); + log(`BLE connect failed for ${deviceId}:`, msg); + this.transport = null; + this.path = null; + return { ok: false, message: `Failed to connect: ${msg}` }; + } finally { + this.connecting = false; + } + } + detach(path: string): void { if (this.path !== path) return; try { @@ -180,6 +224,11 @@ export async function runDaemon(): Promise { send(res, { ok: true, connected: true, response }); }; + // Current Spotify "now playing" state. Set by POST /spotify/track, cleared + // when playback stops. Used by the VS Code extension heartbeat to re-push + // the display without re-fetching from Spotify. + let spotifyDisplay: { track: string; image: string | null } | null = null; + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const url = req.url || "/"; const path = url.split("?")[0]; @@ -247,12 +296,73 @@ export async function runDaemon(): Promise { } return sendCmd(res, `SET mood ${name}`); } + + if (method === "POST" && path === "/status") { + const body = await readJson(req); + const name = body?.name; + if (typeof name !== "string" || !name) { + return send(res, { ok: false, connected: device.isConnected(), message: "missing name" }); + } + const profile = findStatus(name); + if (!profile) { + const valid = STATUS_PROFILES.map((p) => p.name).join(", "); + return send(res, { + ok: false, + connected: device.isConnected(), + message: `unknown status "${name}". Valid: ${valid}`, + }); + } + if (!device.isConnected()) { + return send(res, { ok: true, connected: false, message: "device offline; request ignored" }); + } + // Apply mood first (background pet state), then switch the visible view. + await device.send(`SET mood ${profile.mood}`); + if (profile.text) { + await device.send(`SHOW text ${profile.text}`); + } else { + await device.send(`SHOW face ${profile.face}`); + } + return send(res, { ok: true, connected: true, status: profile.name, label: profile.label }); + } + + if (method === "GET" && path === "/statuses") { + return send(res, { ok: true, statuses: STATUS_PROFILES }); + } + + if (method === "POST" && path === "/spotify/track") { + const body = await readJson(req); + if (body?.track === null || body?.track === "") { + spotifyDisplay = null; + } else if (typeof body?.track === "string") { + spotifyDisplay = { + track: body.track, + image: typeof body.image === "string" && body.image.length > 0 + ? body.image + : null, + }; + } + return send(res, { ok: true, display: spotifyDisplay }); + } + if (method === "GET" && path === "/spotify/track") { + return send(res, { ok: true, ...spotifyDisplay, track: spotifyDisplay?.track ?? null, image: spotifyDisplay?.image ?? null }); + } + if (method === "GET" && path === "/state") return sendCmd(res, "GET state"); if (method === "GET" && path === "/fps") return sendCmd(res, "GET fps"); if (method === "GET" && path === "/faces") return sendCmd(res, "LIST faces"); if (method === "POST" && path === "/ping") return sendCmd(res, "PING"); if (method === "GET" && path === "/i2c") return sendCmd(res, "SCAN i2c"); + if (method === "POST" && path === "/ble/connect") { + const body = await readJson(req); + const deviceId = body?.device; + if (typeof deviceId !== "string" || !deviceId) { + return send(res, { ok: false, message: "missing device" }); + } + const result = await device.connectBLE(deviceId); + return send(res, result); + } + send(res, { ok: false, message: "not found" }, 404); } catch (e: any) { send(res, { ok: false, connected: device.isConnected(), message: e?.message || String(e) }); diff --git a/cli/src/image.ts b/cli/src/image.ts index dbe4812..3ad6689 100644 --- a/cli/src/image.ts +++ b/cli/src/image.ts @@ -39,6 +39,47 @@ export async function fileToFrameBase64( return Buffer.from(bytes).toString("base64"); } +// Convert an in-memory image buffer (PNG, JPEG, …) to a 128x64 1bpp frame +// and return base64. Unlike fileToFrameBase64 this uses `fit: fill` to +// stretch the image to fill the full display — ideal for wide sources such +// as Spotify Codes (~4.84:1 aspect ratio) where bars should be as tall as +// possible rather than letterboxed. +export async function bufferToFrameBase64( + buf: Buffer, + opts: ConvertOptions = {}, +): Promise { + const dither = opts.dither !== false; + const threshold = opts.threshold ?? 128; + const background = opts.background ?? "black"; + const bg = background === "white" + ? { r: 255, g: 255, b: 255, alpha: 1 as const } + : { r: 0, g: 0, b: 0, alpha: 1 as const }; + + const { data, info } = await sharp(buf) + .grayscale() + .resize(FRAME_WIDTH, FRAME_HEIGHT, { fit: "fill", background: bg }) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + if (info.width !== FRAME_WIDTH || info.height !== FRAME_HEIGHT) { + throw new Error(`unexpected resize result: ${info.width}x${info.height}`); + } + + const lum = new Int16Array(FRAME_WIDTH * FRAME_HEIGHT); + for (let i = 0; i < lum.length; i++) lum[i] = data[i]; + + const bits = dither + ? floydSteinberg(lum, FRAME_WIDTH, FRAME_HEIGHT) + : threshold1bpp(lum, threshold); + + if (opts.invert) { + for (let i = 0; i < bits.length; i++) bits[i] ^= 1; + } + + return Buffer.from(packMsbFirst(bits, FRAME_WIDTH, FRAME_HEIGHT)).toString("base64"); +} + // Same as above but returns the raw 1024-byte packed bitmap. export async function fileToFrameBytes( path: string, diff --git a/cli/src/spotify.ts b/cli/src/spotify.ts new file mode 100644 index 0000000..8f04458 --- /dev/null +++ b/cli/src/spotify.ts @@ -0,0 +1,432 @@ +// spotify.ts — Spotify "now playing" integration for the Gochi display. +// +// Auth: OAuth 2.0 Authorization Code + PKCE (no client secret needed). +// Tokens are stored at ~/.tamagotchi/spotify.json. +// +// Commands (wired in cli.ts): +// gochi spotify login — open browser, capture callback, store tokens +// gochi spotify logout — remove stored tokens +// gochi spotify now — one-shot: print + display current track +// gochi spotify watch — poll loop, push track to display (Ctrl-C stops) + +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { createServer } from "node:http"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { exec } from "node:child_process"; + +import { DAEMON_DIR, ensureDaemonDir } from "./ipc.js"; +import * as client from "./client.js"; +import { bufferToFrameBase64 } from "./image.js"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const TOKEN_FILE = join(DAEMON_DIR, "spotify.json"); +const CALLBACK_PORT = 8765; +const CALLBACK_URL = `http://127.0.0.1:${CALLBACK_PORT}/callback`; +const SPOTIFY_SCOPES = "user-read-currently-playing user-read-playback-state"; + +// How often to poll the Spotify API in watch mode (ms). +const POLL_INTERVAL_MS = 5_000; + +// ── Token persistence ────────────────────────────────────────────────────── + +interface TokenData { + clientId: string; + accessToken: string; + refreshToken: string; + expiresAt: number; // epoch ms +} + +function loadTokens(): TokenData | null { + try { + if (!existsSync(TOKEN_FILE)) return null; + return JSON.parse(readFileSync(TOKEN_FILE, "utf8")) as TokenData; + } catch { + return null; + } +} + +function saveTokens(data: TokenData): void { + ensureDaemonDir(); + // 0600 — tokens are sensitive; restrict to owner only. + writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2), { mode: 0o600 }); +} + +function clearTokens(): void { + if (existsSync(TOKEN_FILE)) unlinkSync(TOKEN_FILE); +} + +// ── PKCE helpers ─────────────────────────────────────────────────────────── + +function base64url(buf: Buffer): string { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function generateCodeVerifier(): string { + return base64url(randomBytes(32)); +} + +function generateCodeChallenge(verifier: string): string { + return base64url(createHash("sha256").update(verifier).digest()); +} + +// ── Spotify API calls ────────────────────────────────────────────────────── + +async function exchangeCode( + clientId: string, + code: string, + verifier: string, +): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: CALLBACK_URL, + client_id: clientId, + code_verifier: verifier, + }); + const res = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Token exchange failed (${res.status}): ${text}`); + } + const data = (await res.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + }; +} + +async function refreshAccessToken( + clientId: string, + refreshToken: string, +): Promise<{ accessToken: string; expiresIn: number; newRefreshToken?: string }> { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }); + const res = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Token refresh failed (${res.status}): ${text}`); + } + const data = (await res.json()) as { + access_token: string; + expires_in: number; + refresh_token?: string; + }; + return { + accessToken: data.access_token, + expiresIn: data.expires_in, + newRefreshToken: data.refresh_token, + }; +} + +// ── Track info ───────────────────────────────────────────────────────────── + +export interface TrackInfo { + title: string; + artist: string; + uri: string; // e.g. "spotify:track:4cOdK2wGLETKBW3PvgPWqT" + isPlaying: boolean; +} + +async function fetchCurrentTrack(accessToken: string): Promise { + const res = await fetch("https://api.spotify.com/v1/me/player/currently-playing", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + // 204 = nothing playing; 401 = token expired (handled by caller) + if (res.status === 204 || res.status === 404) return null; + if (!res.ok) throw new Error(`Spotify API error: ${res.status}`); + const data = (await res.json()) as { + is_playing: boolean; + item?: { + name: string; + uri: string; + artists?: Array<{ name: string }>; + }; + }; + if (!data.item) return null; + return { + title: data.item.name, + artist: (data.item.artists ?? []).map((a) => a.name).join(", "), + uri: data.item.uri, + isPlaying: data.is_playing, + }; +} + +// Fetch the Spotify Code scannable image for a track URI, convert it to a +// 128×64 1bpp frame, and return base64 ready for `SHOW image`. +// Spotify's scannables API is public and requires no auth. +// The code image is very wide (~4.84:1), so we stretch it to fill the OLED +// (fit: fill) — the bars are more legible tall than letterboxed tiny. +async function fetchSpotifyCodeImage(uri: string): Promise { + const url = + `https://scannables.scdn.co/uri/plain/png/000000/ffffff/64/` + + encodeURIComponent(uri); + const res = await fetch(url); + if (!res.ok) throw new Error(`Spotify code fetch failed: ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + // dither:false — the code is already pure B&W; dithering would add noise. + return bufferToFrameBase64(buf, { dither: false }); +} + +// ── Token manager (auto-refresh) ─────────────────────────────────────────── + +async function getValidAccessToken(): Promise { + const stored = loadTokens(); + if (!stored) { + throw new Error("Not logged in. Run `gochi spotify login` first."); + } + // Refresh 60 s before expiry so we're never mid-poll when it expires. + if (Date.now() < stored.expiresAt - 60_000) { + return stored.accessToken; + } + const refreshed = await refreshAccessToken(stored.clientId, stored.refreshToken); + const updated: TokenData = { + clientId: stored.clientId, + accessToken: refreshed.accessToken, + refreshToken: refreshed.newRefreshToken ?? stored.refreshToken, + expiresAt: Date.now() + refreshed.expiresIn * 1000, + }; + saveTokens(updated); + return updated.accessToken; +} + +// ── Display helper ───────────────────────────────────────────────────────── + +function trackToDisplayText(track: TrackInfo): string { + // Keep it punchy — the OLED scrolls, but shorter is snappier. + return `${track.title} - ${track.artist}`; +} + +// ── Public commands ──────────────────────────────────────────────────────── + +// Kick off PKCE OAuth flow: open the browser, spin up a one-shot local +// HTTP server to capture the callback, exchange code for tokens. +export async function spotifyLogin(clientId: string): Promise { + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + const state = base64url(randomBytes(8)); + + const authUrl = + `https://accounts.spotify.com/authorize?` + + new URLSearchParams({ + response_type: "code", + client_id: clientId, + scope: SPOTIFY_SCOPES, + redirect_uri: CALLBACK_URL, + state, + code_challenge_method: "S256", + code_challenge: challenge, + }).toString(); + + console.log("\nOpening Spotify login in your browser…"); + console.log("If it doesn't open, visit this URL manually:\n"); + console.log(authUrl + "\n"); + + // Best-effort browser open (macOS / Linux / Windows). + const opener = + process.platform === "darwin" ? "open" : + process.platform === "win32" ? "start" : "xdg-open"; + exec(`${opener} "${authUrl}"`); + + // Spin up a one-shot local server on 127.0.0.1 to catch the callback. + // If the auto-capture fails (e.g. browser doesn't redirect back), + // we also print instructions for manual paste as a fallback. + console.log( + `\nWaiting for Spotify callback on ${CALLBACK_URL}` + + `\nIf the browser doesn't redirect automatically, paste the full redirect URL below.\n`, + ); + + const code = await new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${CALLBACK_PORT}`); + const returnedState = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const code = url.searchParams.get("code"); + + if (error) { + res.end("

Login cancelled.

You can close this tab.

"); + server.close(); + reject(new Error(`Spotify auth error: ${error}`)); + return; + } + if (returnedState !== state || !code) { + res.end("

Bad response.

Please try again.

"); + server.close(); + reject(new Error("State mismatch or missing code — possible CSRF.")); + return; + } + + res.end(` + +

✓ Gochi connected to Spotify!

+

You can close this tab and go back to your terminal.

+ + `); + server.close(); + resolve(code); + }); + + // Bind to 127.0.0.1 only — not accessible from the network. + server.listen(CALLBACK_PORT, "127.0.0.1"); + + // Manual paste fallback: read from stdin in case the auto-redirect + // doesn't fire (some browsers or corp proxies block loopback redirects). + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + process.stdin.once("data", (raw: string) => { + const input = raw.toString().trim(); + try { + // Accept either a full URL or just the bare code. + let extractedCode: string | null = null; + let extractedState: string | null = null; + if (input.startsWith("http")) { + const u = new URL(input); + extractedCode = u.searchParams.get("code"); + extractedState = u.searchParams.get("state"); + } else { + // Bare code — skip state check. + extractedCode = input; + extractedState = state; + } + if (!extractedCode) { + server.close(); + reject(new Error("No code found in pasted URL.")); + return; + } + if (extractedState !== null && extractedState !== state) { + server.close(); + reject(new Error("State mismatch in pasted URL — possible CSRF.")); + return; + } + server.close(); + resolve(extractedCode); + } catch { + server.close(); + reject(new Error("Could not parse pasted URL.")); + } + }); + + // Time out after 5 minutes. + setTimeout(() => { + server.close(); + reject(new Error("Login timed out — no callback received within 5 minutes.")); + }, 300_000); + }); + + const tokens = await exchangeCode(clientId, code, verifier); + saveTokens({ + clientId, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: Date.now() + tokens.expiresIn * 1000, + }); + + console.log("✓ Logged in! Tokens saved to ~/.tamagotchi/spotify.json"); +} + +export function spotifyLogout(): void { + clearTokens(); + console.log("Logged out — Spotify tokens removed."); +} + +// One-shot: fetch current track, print it, push to display. +export async function spotifyNow(): Promise { + const token = await getValidAccessToken(); + const track = await fetchCurrentTrack(token); + + if (!track) { + console.log("Nothing is currently playing on Spotify."); + return; + } + + const text = trackToDisplayText(track); + const status = track.isPlaying ? "▶" : "⏸"; + console.log(`${status} ${text}`); + // Try to show the Spotify Code image; fall back to scrolling text. + const image = await fetchSpotifyCodeImage(track.uri).catch(() => null); + if (image) { + await client.image(image); + await client.setSpotifyTrack(text, image); + } else { + await client.text(text); + await client.setSpotifyTrack(text, null); + } +} + +// Polling loop: push track to display every POLL_INTERVAL_MS. +// Exits cleanly on Ctrl-C. +export async function spotifyWatch(): Promise { + console.log(`Watching Spotify… (updates every ${POLL_INTERVAL_MS / 1000}s, Ctrl-C to stop)\n`); + + let lastText = ""; + + async function tick(): Promise { + try { + const token = await getValidAccessToken(); + const track = await fetchCurrentTrack(token); + + if (!track) { + if (lastText !== "") { + console.log("— Nothing playing"); + await client.setSpotifyTrack(null); + lastText = ""; + } + return; + } + + const text = trackToDisplayText(track); + if (text !== lastText) { + const status = track.isPlaying ? "▶" : "⏸"; + console.log(`${status} ${text}`); + // Try to show the Spotify Code image; fall back to scrolling text. + const image = await fetchSpotifyCodeImage(track.uri).catch(() => null); + if (image) { + await client.image(image); + await client.setSpotifyTrack(text, image); + } else { + await client.text(text); + await client.setSpotifyTrack(text, null); + } + lastText = text; + } + } catch (e: any) { + console.error("Spotify error:", e?.message ?? e); + } + } + + // Run once immediately, then on interval. + await tick(); + const handle = setInterval(() => void tick(), POLL_INTERVAL_MS); + + // Clean shutdown on Ctrl-C. + process.on("SIGINT", () => { + clearInterval(handle); + // Clear the daemon state so the VS Code extension heartbeat can resume + // showing the project/state label immediately. + void client.setSpotifyTrack(null).finally(() => { + console.log("\nStopped."); + process.exit(0); + }); + }); + + // Keep the process alive. + await new Promise(() => {}); +} diff --git a/cli/src/status.ts b/cli/src/status.ts new file mode 100644 index 0000000..3e07cfa --- /dev/null +++ b/cli/src/status.ts @@ -0,0 +1,99 @@ +// status.ts — availability status profiles for office use. +// +// Each profile maps a human-friendly label to the face expression, pet mood, +// and optional scrolling text that best communicates your current state. +// When `text` is set, the text view is shown (readable at a glance by +// colleagues); otherwise the face expression is shown. + +export interface StatusProfile { + name: string; // CLI slug used in the protocol (e.g. "in-meeting") + label: string; // Human-readable label shown in the picker + description: string; // Short description / tooltip + face: string; // Expression name (matches firmware ExpressionId names) + mood: string; // Mood name (matches firmware Mood names) + text?: string; // Scrolling text to display (overrides face view when set) +} + +export const STATUS_PROFILES: StatusProfile[] = [ + { + name: "available", + label: "Available", + description: "Free to chat and collaborate", + face: "happy", + mood: "content", + }, + { + name: "busy", + label: "Busy", + description: "Working — keep interruptions to a minimum", + face: "neutral", + mood: "grumpy", + }, + { + name: "in-meeting", + label: "In Meeting", + description: "Currently in a meeting", + face: "neutral", + mood: "content", + text: "In Meeting", + }, + { + name: "deep-focus", + label: "Deep Focus", + description: "Flow state — please don't interrupt", + face: "sleepy", + mood: "sleepy", + text: "Deep Focus", + }, + { + name: "frustrated", + label: "Frustrated", + description: "Hitting blockers or feeling stressed", + face: "angry", + mood: "grumpy", + }, + { + name: "on-break", + label: "On Break", + description: "Coffee or lunch — back soon", + face: "excited", + mood: "playful", + text: "On Break!", + }, + { + name: "away", + label: "Away", + description: "Stepped away from my desk", + face: "sleepy", + mood: "sleepy", + text: "Away", + }, + { + name: "do-not-disturb", + label: "Do Not Disturb", + description: "Absolute focus — hold all messages", + face: "dead", + mood: "grumpy", + text: "DND", + }, + { + name: "reviewing", + label: "Reviewing", + description: "In a code or document review", + face: "surprised", + mood: "content", + text: "Reviewing", + }, + { + name: "thinking", + label: "Thinking", + description: "Problem-solving mode — give me a moment", + face: "surprised", + mood: "playful", + }, +]; + +// Case-insensitive lookup by slug name. +export function findStatus(name: string): StatusProfile | undefined { + return STATUS_PROFILES.find((p) => p.name.toLowerCase() === name.toLowerCase()); +} diff --git a/cli/src/transport_ble.ts b/cli/src/transport_ble.ts new file mode 100644 index 0000000..b9f1933 --- /dev/null +++ b/cli/src/transport_ble.ts @@ -0,0 +1,192 @@ +// transport_ble.ts — BLE transport for Gochi device communication. + +import { EventEmitter } from "node:events"; +import { createBluetooth } from "node-ble"; + +const SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; +const CHAR_TX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; // Device → Client (notify) +const CHAR_RX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; // Client → Device (write) + +// BLETransport connects to a Gochi device over BLE using the Nordic UART +// Service. It provides the same API as SerialTransport for compatibility. +export class BLETransport extends EventEmitter { + private device: any = null; + private gattServer: any = null; + private rxChar: any = null; + private txChar: any = null; + private connected = false; + private lineBuffer = ""; + private bluetooth: any = null; + private adapter: any = null; + + constructor(readonly deviceId: string) { + super(); + } + + isOpen(): boolean { + return this.connected; + } + + async open(): Promise { + try { + const { bluetooth, destroy } = createBluetooth(); + this.bluetooth = { bluetooth, destroy }; + this.adapter = await bluetooth.defaultAdapter(); + + if (! await this.adapter.isDiscovering()) { + await this.adapter.startDiscovery(); + } + + // Wait a bit for discovery + await new Promise(resolve => setTimeout(resolve, 2000)); + + const devices = await this.adapter.devices(); + let targetDevice = null; + + for (const deviceAddr of devices) { + const device = await this.adapter.getDevice(deviceAddr); + const name = await device.getName().catch(() => ""); + + if (name.startsWith(this.deviceId) || deviceAddr === this.deviceId) { + targetDevice = device; + break; + } + } + + await this.adapter.stopDiscovery(); + + if (!targetDevice) { + throw new Error(`Device ${this.deviceId} not found`); + } + + this.device = targetDevice; + await this.connectAndSetup(); + this.connected = true; + } catch (err: any) { + if (this.adapter) { + await this.adapter.stopDiscovery().catch(() => {}); + } + throw new Error(`BLE connection failed: ${err.message}`); + } + } + + private async connectAndSetup(): Promise { + if (!this.device) throw new Error("No device"); + + await this.device.connect(); + this.gattServer = await this.device.gatt(); + + const service = await this.gattServer.getPrimaryService(SERVICE_UUID); + + this.txChar = await service.getCharacteristic(CHAR_TX_UUID); + this.rxChar = await service.getCharacteristic(CHAR_RX_UUID); + + if (!this.txChar || !this.rxChar) { + throw new Error("Required BLE characteristics not found"); + } + + // Subscribe to TX characteristic (device notifications). + await this.txChar.startNotifications(); + this.txChar.on("valuechanged", (data: Buffer) => { + const text = data.toString("utf-8"); + this.lineBuffer += text; + + // Emit complete lines. + let newlineIndex; + while ((newlineIndex = this.lineBuffer.indexOf("\n")) >= 0) { + const line = this.lineBuffer.substring(0, newlineIndex).replace(/\r$/, ""); + this.lineBuffer = this.lineBuffer.substring(newlineIndex + 1); + this.emit("line", line); + } + }); + + this.device.on("disconnect", () => { + this.connected = false; + this.emit("close"); + }); + } + + async send(line: string, timeoutMs = 1500): Promise { + if (!this.connected || !this.rxChar) throw new Error("not open"); + + return new Promise((resolve) => { + const onLine = (raw: string) => { + const r = raw.replace(/\r$/, ""); + if (isBanner(r)) return; // boot banner; keep waiting + cleanup(); + resolve(r); + }; + + const timer = setTimeout(() => { + cleanup(); + resolve(null); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timer); + this.off("line", onLine); + }; + + this.on("line", onLine); + + // Write to RX characteristic (client → device). + const data = Buffer.from(line + "\n", "utf-8"); + this.rxChar.writeValue(data).catch(() => { + cleanup(); + resolve(null); + }); + }); + } + + async close(): Promise { + if (this.device && this.connected) { + await this.device.disconnect().catch(() => {}); + this.connected = false; + } + if (this.bluetooth) { + this.bluetooth.destroy(); + } + } +} + +function isBanner(line: string): boolean { + return line.startsWith("Tamagotchi "); +} + +// Discover nearby Gochi devices advertising the Nordic UART Service. +export async function discoverDevices(timeoutMs = 5000): Promise> { + const { bluetooth, destroy } = createBluetooth(); + + try { + const adapter = await bluetooth.defaultAdapter(); + + if (! await adapter.isDiscovering()) { + await adapter.startDiscovery(); + } + + // Wait for discovery + await new Promise(resolve => setTimeout(resolve, timeoutMs)); + + await adapter.stopDiscovery(); + + const devices = await adapter.devices(); + const results: Array<{ name: string; address: string }> = []; + + for (const deviceAddr of devices) { + const device = await adapter.getDevice(deviceAddr); + const name = await device.getName().catch(() => ""); + + // Only include devices that look like Gochi devices. + if (name.startsWith("Gochi-")) { + results.push({ name, address: deviceAddr }); + } + } + + destroy(); + return results; + } catch (err: any) { + destroy(); + throw new Error(`BLE scan failed: ${err.message}`); + } +} + diff --git a/cli/src/transport_interface.ts b/cli/src/transport_interface.ts new file mode 100644 index 0000000..7bf1b37 --- /dev/null +++ b/cli/src/transport_interface.ts @@ -0,0 +1,12 @@ +// transport_interface.ts — common interface for Serial and BLE transports. + +import { EventEmitter } from "node:events"; + +// Common interface for both SerialTransport and BLETransport so the +// daemon can manage either type without caring which one it is. +export interface ITransport extends EventEmitter { + isOpen(): boolean; + open(): Promise; + send(line: string, timeoutMs?: number): Promise; + close(): Promise; +} diff --git a/cli/test-ble.js b/cli/test-ble.js new file mode 100644 index 0000000..90f822f --- /dev/null +++ b/cli/test-ble.js @@ -0,0 +1,31 @@ +import { request } from 'node:http'; + +const req = request( + { + socketPath: '/Users/harshitruwali/.tamagotchi/daemon.sock', + method: 'POST', + path: '/ble/connect', + headers: { 'Content-Type': 'application/json' }, + }, + (res) => { + let buf = ''; + res.on('data', (c) => (buf += c)); + res.on('end', () => { + console.log('Response:', buf); + process.exit(0); + }); + } +); + +req.on('error', (e) => { + console.error('Error:', e.message); + process.exit(1); +}); + +req.write(JSON.stringify({ device: 'Gochi-EE74' })); +req.end(); + +setTimeout(() => { + console.log('Timeout - no response after 10s'); + process.exit(1); +}, 10000); diff --git a/cli/test-noble.js b/cli/test-noble.js new file mode 100644 index 0000000..a2c949c --- /dev/null +++ b/cli/test-noble.js @@ -0,0 +1,20 @@ +import noble from '@abandonware/noble'; + +console.log('Noble state:', noble.state); + +noble.on('stateChange', (state) => { + console.log('State changed to:', state); + if (state === 'poweredOn') { + console.log('Bluetooth is ready'); + process.exit(0); + } else { + console.log('Bluetooth not ready:', state); + process.exit(1); + } +}); + +setTimeout(() => { + console.log('Timeout - noble state never became poweredOn'); + console.log('Final state:', noble.state); + process.exit(1); +}, 5000); diff --git a/firmware/firmware.ino b/firmware/firmware.ino index 15fddd7..04ef45e 100644 --- a/firmware/firmware.ino +++ b/firmware/firmware.ino @@ -24,6 +24,7 @@ #include "src/mood.h" #include "src/renderer.h" #include "src/transport.h" +#include "src/transport_ble.h" #include "src/views/view_manager.h" // Flip to 0 to disable Free Mode: the pet sits in Desktop Mode forever @@ -32,12 +33,20 @@ // instead of handing back to autonomous behaviour). #define FREE_MODE_ENABLED 1 +// Flip to 1 to enable BLE transport alongside USB Serial. When enabled, +// the device advertises as "Gochi-XXXX" and accepts commands over both +// USB and BLE simultaneously. Responses are sent to both transports. +#define BLE_ENABLED 1 + // The pet's mood — shared between the modes: SET mood writes it, Free Mode // reads and slowly evolves it. RAM-only (a reboot resets it to content). static Mood petMood = Mood::Content; static Renderer renderer; static Transport transport; +#if BLE_ENABLED +static BLETransport bleTransport; +#endif static ViewManager viewManager; static DesktopMode desktopMode(transport, renderer, petMood); #if FREE_MODE_ENABLED @@ -106,6 +115,13 @@ static bool bootButtonPressed(uint32_t now) { void setup() { transport.begin(115200); + +#if BLE_ENABLED + bleTransport.begin("Gochi"); + // Configure USB Serial transport to broadcast responses to BLE too. + transport.setBLEBroadcast(&bleTransport); +#endif + renderer.init(); buzzer::begin(); @@ -121,9 +137,17 @@ void setup() { currentMode->onEnter(viewManager); #if FREE_MODE_ENABLED + #if BLE_ENABLED + transport.println("Tamagotchi ready (Free Mode, BLE enabled). Send any command for Desktop Mode."); + #else transport.println("Tamagotchi ready (Free Mode). Send any command for Desktop Mode."); + #endif #else + #if BLE_ENABLED + transport.println("Tamagotchi ready (Desktop Mode, BLE enabled — Free Mode disabled)."); + #else transport.println("Tamagotchi ready (Desktop Mode — Free Mode disabled)."); + #endif #endif } @@ -131,7 +155,21 @@ void loop() { uint32_t now = millis(); Command cmd; + bool gotCommand = false; + + // Poll USB Serial transport. if (transport.poll(cmd)) { + gotCommand = true; + } + +#if BLE_ENABLED + // Poll BLE transport. + if (!gotCommand && bleTransport.poll(cmd)) { + gotCommand = true; + } +#endif + + if (gotCommand) { lastCmdMs = now; setMode(desktopMode); // any command means a host is driving the pet currentMode->onCommand(cmd, viewManager); @@ -217,8 +255,10 @@ void loop() { currentMode->update(now, viewManager); // Buzzer synced to the face. In Desktop Mode every expression change - // jingles; Free Mode stays quieter and plays its own jingle only on - // mood shifts, so it is not gated in here. + // jingles; Free Mode now also plays a jingle on every expression change + // (mood shifts go through pickExpression_ directly; expression ticks + // do too, so this gate only handles Desktop Mode's path to avoid + // double-playing when Free Mode has already called buzzer::play). ExpressionId expr = viewManager.face().expression(); if (expr != jingledExpr) { jingledExpr = expr; diff --git a/firmware/src/modes/free_mode.cpp b/firmware/src/modes/free_mode.cpp index 507d5a0..283bbce 100644 --- a/firmware/src/modes/free_mode.cpp +++ b/firmware/src/modes/free_mode.cpp @@ -107,6 +107,6 @@ void FreeMode::update(uint32_t now, ViewManager& vm) { scheduleMood_(now); pickExpression_(now, vm, true); // a mood shift gets a (quiet) jingle } else if (static_cast(now - nextExprMs_) >= 0) { - pickExpression_(now, vm, false); // a routine expression tick — silent + pickExpression_(now, vm, true); // a routine expression tick — play jingle } } diff --git a/firmware/src/transport.cpp b/firmware/src/transport.cpp index 029656d..c12f560 100644 --- a/firmware/src/transport.cpp +++ b/firmware/src/transport.cpp @@ -1,6 +1,7 @@ // transport.cpp — buffered serial line transport (see transport.h). #include "transport.h" +#include "transport_ble.h" #include @@ -39,4 +40,14 @@ bool Transport::poll(Command& out) { return false; } -void Transport::println(const char* s) { Serial.println(s); } +void Transport::println(const char* s) { + Serial.println(s); + // Broadcast to BLE if configured and connected. + if (ble_ != nullptr && ble_->isConnected()) { + ble_->println(s); + } +} + +void Transport::setBLEBroadcast(BLETransport* ble) { + ble_ = ble; +} diff --git a/firmware/src/transport.h b/firmware/src/transport.h index ba33227..e6822ac 100644 --- a/firmware/src/transport.h +++ b/firmware/src/transport.h @@ -9,6 +9,8 @@ #include "command.h" +class BLETransport; // Forward declaration for optional BLE broadcasting + class Transport { public: // Open the serial port at the given baud rate. @@ -20,6 +22,10 @@ class Transport { // Send one response line (a newline is appended). void println(const char* s); + + // Register a BLE transport for response broadcasting. When set, println() + // sends to both USB Serial and BLE. + void setBLEBroadcast(BLETransport* ble); private: // Sized to hold `SHOW image ` for a full 128x64 1bpp bitmap: @@ -28,4 +34,5 @@ class Transport { char buf_[kLineCap]; size_t len_ = 0; bool overflow_ = false; + BLETransport* ble_ = nullptr; }; diff --git a/firmware/src/transport_ble.cpp b/firmware/src/transport_ble.cpp new file mode 100644 index 0000000..82ce476 --- /dev/null +++ b/firmware/src/transport_ble.cpp @@ -0,0 +1,126 @@ +// transport_ble.cpp — BLE UART transport implementation. + +#include "transport_ble.h" +#include +#include + +bool BLETransport::begin(const char* deviceNamePrefix) { + // Generate device name with last 4 hex digits of MAC address. + uint64_t macInt = ESP.getEfuseMac(); + uint8_t mac[6]; + for (int i = 0; i < 6; i++) { + mac[i] = (macInt >> (8 * i)) & 0xFF; + } + char deviceName[32]; + snprintf(deviceName, sizeof(deviceName), "%s-%02X%02X", + deviceNamePrefix, mac[4], mac[5]); + + // Initialize BLE. + BLEDevice::init(deviceName); + + // Create BLE server and set callbacks. + server_ = BLEDevice::createServer(); + server_->setCallbacks(new GochiServerCallbacks(this)); + + // Create service with Nordic UART Service UUID. + BLEService* service = server_->createService(SERVICE_UUID); + + // TX characteristic (device → client notifications). + txChar_ = service->createCharacteristic( + CHARACTERISTIC_UUID_TX, + BLECharacteristic::PROPERTY_NOTIFY + ); + txChar_->addDescriptor(new BLE2902()); + + // RX characteristic (client → device writes). + rxChar_ = service->createCharacteristic( + CHARACTERISTIC_UUID_RX, + BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR + ); + rxChar_->setCallbacks(new GochiCharacteristicCallbacks(this)); + + // Start service and advertising. + service->start(); + + BLEAdvertising* advertising = BLEDevice::getAdvertising(); + advertising->addServiceUUID(SERVICE_UUID); + advertising->setScanResponse(true); + // Faster connection with iPhone (workaround for iOS connection issues). + advertising->setMinPreferred(0x06); + advertising->setMaxPreferred(0x12); + + BLEDevice::startAdvertising(); + advertising_ = true; + + return true; +} + +void BLETransport::handleChar(char c) { + if (c == '\n' || c == '\r') { + if (len_ == 0 && !overflow_) return; // blank line / stray CR or LF + buf_[len_] = '\0'; + + // Mark line as ready if it wasn't an overflow. + if (!overflow_) { + lineReady_ = true; + } + + // Reset overflow state. + if (overflow_) { + len_ = 0; + overflow_ = false; + } + return; + } + + if (len_ + 1 < kLineCap) { + buf_[len_++] = c; + } else { + overflow_ = true; // line too long — drop the rest until newline + } +} + +bool BLETransport::poll(Command& out) { + // Check if we have a complete line ready. + if (lineReady_) { + out = parseLine(buf_); + len_ = 0; + lineReady_ = false; + return true; + } + + return false; +} + +void BLETransport::println(const char* s) { + if (!connected_ || !txChar_) return; + + // BLE characteristics have a max size (typically 512 bytes, but safer + // to chunk). We'll send in chunks of 512 bytes max. + size_t len = strlen(s); + const size_t chunkSize = 512; + + // Send the string in chunks if needed. + for (size_t i = 0; i < len; i += chunkSize) { + size_t remaining = len - i; + size_t toSend = remaining < chunkSize ? remaining : chunkSize; + txChar_->setValue((uint8_t*)(s + i), toSend); + txChar_->notify(); + delay(10); // Small delay between chunks for reliability + } + + // Send newline. + txChar_->setValue((uint8_t*)"\n", 1); + txChar_->notify(); +} + +void BLETransport::end() { + if (advertising_) { + BLEDevice::getAdvertising()->stop(); + advertising_ = false; + } + if (server_) { + // BLE library handles cleanup + } + BLEDevice::deinit(); +} diff --git a/firmware/src/transport_ble.h b/firmware/src/transport_ble.h new file mode 100644 index 0000000..127450a --- /dev/null +++ b/firmware/src/transport_ble.h @@ -0,0 +1,98 @@ +// transport_ble.h — BLE UART transport for wireless communication. +// +// Provides the same line-based protocol as transport.h but over BLE +// using the Nordic UART Service (NUS) UUID scheme. The device advertises +// as "Gochi-XXXX" where XXXX are the last 4 hex digits of the MAC address. +// Compatible with nRF Connect, LightBlue, and custom clients. +#pragma once + +#include +#include +#include +#include +#include + +#include "command.h" + +// Nordic UART Service UUIDs (widely supported standard) +#define SERVICE_UUID "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +#define CHARACTERISTIC_UUID_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" // Write +#define CHARACTERISTIC_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" // Notify + +class BLETransport { + public: + // Initialize BLE with device name "Gochi-XXXX" and start advertising. + // Returns true on success, false if BLE init fails. + bool begin(const char* deviceNamePrefix = "Gochi"); + + // Non-blocking: if a complete newline-terminated line has arrived via + // BLE, parse it into `out` and return true. Otherwise return false. + bool poll(Command& out); + + // Send one response line via BLE notification (newline is appended). + // Does nothing if no client is connected. + void println(const char* s); + + // Check if a BLE client is currently connected. + bool isConnected() const { return connected_; } + + // Stop advertising and clean up BLE resources. + void end(); + + private: + friend class GochiServerCallbacks; + friend class GochiCharacteristicCallbacks; + + BLEServer* server_ = nullptr; + BLECharacteristic* txChar_ = nullptr; + BLECharacteristic* rxChar_ = nullptr; + bool connected_ = false; + bool advertising_ = false; + + // Incoming line buffer — same size as Transport to handle full SHOW image. + static const size_t kLineCap = 1536; + char buf_[kLineCap]; + size_t len_ = 0; + bool overflow_ = false; + bool lineReady_ = false; // True when a complete line is waiting + + // Process one incoming character from BLE RX characteristic. + void handleChar(char c); +}; + +// BLE callbacks to track connection state. +class GochiServerCallbacks : public BLEServerCallbacks { + public: + GochiServerCallbacks(BLETransport* transport) : transport_(transport) {} + + void onConnect(BLEServer* server) override { + transport_->connected_ = true; + transport_->advertising_ = false; + } + + void onDisconnect(BLEServer* server) override { + transport_->connected_ = false; + // Restart advertising after disconnect so client can reconnect. + server->startAdvertising(); + transport_->advertising_ = true; + } + + private: + BLETransport* transport_; +}; + +// RX characteristic callbacks to receive incoming data. +class GochiCharacteristicCallbacks : public BLECharacteristicCallbacks { + public: + GochiCharacteristicCallbacks(BLETransport* transport) : transport_(transport) {} + + void onWrite(BLECharacteristic* characteristic) override { + String value = characteristic->getValue(); + for (size_t i = 0; i < value.length(); i++) { + transport_->handleChar(value[i]); + } + } + + private: + BLETransport* transport_; +}; diff --git a/firmware/src/transport_multi.h b/firmware/src/transport_multi.h new file mode 100644 index 0000000..68babeb --- /dev/null +++ b/firmware/src/transport_multi.h @@ -0,0 +1,27 @@ +// transport_multi.h — transport multiplexer for dual USB+BLE. +// +// Wraps both Transport (USB Serial) and BLETransport and broadcasts +// println() calls to both. Used by DesktopMode so responses reach +// whichever transport the command came from (or both if needed). +#pragma once + +#include "transport.h" +#include "transport_ble.h" + +class MultiTransport { + public: + MultiTransport(Transport& serial, BLETransport& ble) + : serial_(serial), ble_(ble) {} + + // Send response to both USB Serial and BLE (if connected). + void println(const char* s) { + serial_.println(s); + if (ble_.isConnected()) { + ble_.println(s); + } + } + + private: + Transport& serial_; + BLETransport& ble_; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..668e188 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{"dependencies": {}} \ No newline at end of file diff --git a/vscode-extension/out/extension.js b/vscode-extension/out/extension.js new file mode 100644 index 0000000..f01c7eb --- /dev/null +++ b/vscode-extension/out/extension.js @@ -0,0 +1,211 @@ +"use strict"; +// extension.ts — VS Code extension entry point for Gochi Activity Watcher. +// +// Activates on startup (onStartupFinished) and: +// 1. Creates an HTTP client for the Gochi daemon's HTTP frontend. +// 2. Starts the ActivityWatcher (event subscriptions + state machine). +// 3. Shows a status-bar item reflecting the current auto-detected state. +// 4. Registers two commands: +// gochi.toggleAutoMode — enable / disable auto updates +// gochi.setStatus — manually pick a status (pauses auto for 30 min) +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.activate = activate; +exports.deactivate = deactivate; +const vscode = __importStar(require("vscode")); +const gochi_client_js_1 = require("./gochi-client.js"); +const watcher_js_1 = require("./watcher.js"); +// All available status names (mirrors cli/src/status.ts — kept in sync manually). +const STATUS_NAMES = [ + { name: "available", label: "Available", description: "Free to chat and collaborate" }, + { name: "busy", label: "Busy", description: "Working — keep interruptions to a minimum" }, + { name: "in-meeting", label: "In Meeting", description: "Currently in a meeting" }, + { name: "deep-focus", label: "Deep Focus", description: "Flow state — please don't interrupt" }, + { name: "frustrated", label: "Frustrated", description: "Hitting blockers or feeling stressed" }, + { name: "on-break", label: "On Break", description: "Coffee or lunch — back soon" }, + { name: "away", label: "Away", description: "Stepped away from my desk" }, + { name: "do-not-disturb", label: "Do Not Disturb", description: "Absolute focus — hold all messages" }, + { name: "reviewing", label: "Reviewing", description: "In a code or document review" }, + { name: "thinking", label: "Thinking", description: "Problem-solving mode" }, +]; +function activate(context) { + const url = vscode.workspace + .getConfiguration("gochi") + .get("daemonUrl", "http://localhost:7474"); + const client = new gochi_client_js_1.GochiClient(url); + // Resolve the project label: explicit setting takes priority, then the + // workspace folder name, then empty (no overlay). + function resolveProjectLabel() { + const cfg = vscode.workspace + .getConfiguration("gochi") + .get("projectLabel", ""); + if (cfg.trim()) + return cfg.trim(); + const folders = vscode.workspace.workspaceFolders; + if (folders && folders.length > 0) + return folders[0].name; + return ""; + } + // Status-bar item — click it to quickly pick a status. + const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 50); + statusBar.command = "gochi.setStatus"; + statusBar.tooltip = "Gochi desk pet status (click to set manually)"; + context.subscriptions.push(statusBar); + function updateStatusBar(state) { + const enabled = vscode.workspace + .getConfiguration("gochi") + .get("autoMode.enabled", true); + if (!enabled) { + statusBar.text = "$(circle-slash) Gochi (paused)"; + statusBar.tooltip = "Auto-mode disabled — click to set status"; + } + else if (watcher.isManualOverrideActive()) { + const mins = watcher.manualOverrideRemainingMinutes(); + statusBar.text = `$(lock) ${watcher_js_1.STATE_LABEL[state].replace(/^\$\([^)]+\) /, "")} (manual, ${mins}m)`; + statusBar.tooltip = "Manual override active — click to resume auto-mode or pick a new status"; + } + else { + statusBar.text = watcher_js_1.STATE_LABEL[state]; + statusBar.tooltip = "Gochi auto-mode active — click to set status manually"; + } + statusBar.show(); + } + const watcher = new watcher_js_1.ActivityWatcher(client, (state) => { + updateStatusBar(state); + }); + context.subscriptions.push(watcher); + // Seed project label from config / workspace folder. + watcher.setProjectLabel(resolveProjectLabel()); + // Show initial state. + updateStatusBar(watcher.currentState()); + // Ping the daemon once so the user sees a warning if it isn't reachable. + void client.health().then((h) => { + if (h === null) { + vscode.window.showWarningMessage("Gochi: cannot reach the HTTP frontend. Run `gochi server enable` in your terminal.", "Dismiss"); + } + else if (!h.connected) { + vscode.window.showInformationMessage("Gochi: daemon is running but the device is not connected."); + } + }); + // ── Commands ────────────────────────────────────────────────────────── + context.subscriptions.push(vscode.commands.registerCommand("gochi.toggleAutoMode", () => { + const cfg = vscode.workspace.getConfiguration("gochi"); + const current = cfg.get("autoMode.enabled", true); + void cfg + .update("autoMode.enabled", !current, vscode.ConfigurationTarget.Global) + .then(() => { + const nowEnabled = !current; + updateStatusBar(watcher.currentState()); + vscode.window.showInformationMessage(nowEnabled + ? "Gochi auto-mode enabled — the pet now mirrors your activity." + : "Gochi auto-mode paused — use `Gochi: Set Status` to update manually."); + }); + })); + context.subscriptions.push(vscode.commands.registerCommand("gochi.setStatus", async () => { + // If override is active, offer a quick "resume" shortcut at the top. + if (watcher.isManualOverrideActive()) { + const mins = watcher.manualOverrideRemainingMinutes(); + const resume = await vscode.window.showQuickPick([ + { label: "$(play) Resume auto-mode", description: `Cancel the ${mins}m manual lock`, value: "__resume__" }, + { label: "$(edit) Set a different status…", description: "Pick a new status (resets the timer)", value: "__pick__" }, + ], { title: `Gochi — manual override active (${mins}m left)`, placeHolder: "" }); + if (!resume) + return; + if (resume.value === "__resume__") { + watcher.clearManualOverride(); + updateStatusBar(watcher.currentState()); + vscode.window.showInformationMessage("Gochi: auto-mode resumed."); + return; + } + // fall through to the full picker below + } + const picked = await vscode.window.showQuickPick(STATUS_NAMES.map((s) => ({ + label: s.label, + description: s.description, + detail: s.name, // shown as small text, also used as the API slug + })), { + title: "Set Gochi Status", + placeHolder: "Choose your current availability…", + matchOnDescription: true, + }); + if (!picked) + return; // user cancelled + const ok = await client.setStatus(picked.detail); + if (ok) { + watcher.notifyManualOverride(); + // If a project label is active, follow the status with a project overlay. + const label = watcher.projectLabel(); + if (label) { + await client.setText(`${label} | ${picked.label}`); + } + statusBar.text = `$(check) ${picked.label}`; + const mins = vscode.workspace + .getConfiguration("gochi") + .get("autoMode.manualOverrideMinutes", 30); + setTimeout(() => updateStatusBar(watcher.currentState()), 2000); + const choice = await vscode.window.showInformationMessage(`Gochi: status set to "${picked.label}". Auto-mode paused for ${mins} min.`, "Resume Auto"); + if (choice === "Resume Auto") { + watcher.clearManualOverride(); + updateStatusBar(watcher.currentState()); + } + } + else { + vscode.window.showErrorMessage("Gochi: failed to set status — is the HTTP frontend running? (`gochi server enable`)"); + } + })); + // React to config changes (e.g. user toggling autoMode from settings UI). + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("gochi.autoMode.enabled")) { + updateStatusBar(watcher.currentState()); + } + if (e.affectsConfiguration("gochi.projectLabel")) { + watcher.setProjectLabel(resolveProjectLabel()); + } + if (e.affectsConfiguration("gochi.daemonUrl")) { + // Restart with the new URL requires a reload — prompt the user. + void vscode.window + .showInformationMessage("Gochi: daemon URL changed. Reload the window to apply.", "Reload") + .then((choice) => { + if (choice === "Reload") { + void vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); + } + })); +} +function deactivate() { + // Subscriptions and watcher are cleaned up via context.subscriptions. +} +//# sourceMappingURL=extension.js.map \ No newline at end of file diff --git a/vscode-extension/out/extension.js.map b/vscode-extension/out/extension.js.map new file mode 100644 index 0000000..a65dd40 --- /dev/null +++ b/vscode-extension/out/extension.js.map @@ -0,0 +1 @@ +{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/vscode-extension/out/gochi-client.js b/vscode-extension/out/gochi-client.js new file mode 100644 index 0000000..b7e00b5 --- /dev/null +++ b/vscode-extension/out/gochi-client.js @@ -0,0 +1,94 @@ +"use strict"; +// gochi-client.ts — thin HTTP client for the Gochi HTTP frontend. +// Talks to the TCP server started by `gochi server enable` (default :7474). +// All calls are fire-and-forget safe: they swallow network errors so a +// disconnected device or disabled frontend never crashes the extension. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.GochiClient = void 0; +class GochiClient { + baseUrl; + constructor(baseUrl) { + this.baseUrl = baseUrl; + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + async health() { + try { + const res = await fetch(`${this.baseUrl}/health`, { + signal: AbortSignal.timeout(2000), + }); + return (await res.json()); + } + catch { + return null; + } + } + // Apply a named availability-status profile (face + mood + optional text). + async setStatus(name) { + try { + const res = await fetch(`${this.baseUrl}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + signal: AbortSignal.timeout(3000), + }); + const data = (await res.json()); + return data.ok === true; + } + catch { + return false; + } + } + // Show arbitrary scrolling text on the display. + async setText(text) { + try { + const res = await fetch(`${this.baseUrl}/text`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + signal: AbortSignal.timeout(3000), + }); + const data = (await res.json()); + return data.ok === true; + } + catch { + return false; + } + } + // Send a 128×64 1bpp frame (base64) to the display via SHOW image. + async setImage(data) { + try { + const res = await fetch(`${this.baseUrl}/image`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data }), + signal: AbortSignal.timeout(3000), + }); + const json = (await res.json()); + return json.ok === true; + } + catch { + return false; + } + } + // Return the current Spotify "now playing" state stored in the daemon, + // or null if nothing is playing / Spotify watch is not running. + async getSpotifyTrack() { + try { + const res = await fetch(`${this.baseUrl}/spotify/track`, { + signal: AbortSignal.timeout(2000), + }); + const data = (await res.json()); + if (!data.ok) + return null; + return { + track: typeof data.track === "string" ? data.track : null, + image: typeof data.image === "string" ? data.image : null, + }; + } + catch { + return null; + } + } +} +exports.GochiClient = GochiClient; +//# sourceMappingURL=gochi-client.js.map \ No newline at end of file diff --git a/vscode-extension/out/gochi-client.js.map b/vscode-extension/out/gochi-client.js.map new file mode 100644 index 0000000..4fdfbfa --- /dev/null +++ b/vscode-extension/out/gochi-client.js.map @@ -0,0 +1 @@ +{"version":3,"file":"gochi-client.js","sourceRoot":"","sources":["../src/gochi-client.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/vscode-extension/out/watcher.js b/vscode-extension/out/watcher.js new file mode 100644 index 0000000..a90af14 --- /dev/null +++ b/vscode-extension/out/watcher.js @@ -0,0 +1,318 @@ +"use strict"; +// watcher.ts — VS Code activity → Gochi status state machine. +// +// Detected states and what triggers them: +// +// available — default; also restored after debugging / errors clear +// deep-focus — sustained typing for N seconds (no debug, no spike) +// thinking — a debug session is active +// frustrated — N new errors vs baseline, OR a task/build exits non-zero +// away — no keyboard / editor activity for N minutes +// +// Priority (highest wins when multiple signals fire at once): +// thinking > frustrated > deep-focus > available > away +// +// Manual override: if the user calls `gochi status` from the CLI or the +// command palette, the watcher backs off for `manualOverrideMinutes` so +// it doesn't immediately override what was chosen. +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ActivityWatcher = exports.STATE_LABEL = void 0; +const vscode = __importStar(require("vscode")); +// Human-readable label shown in the status bar. +exports.STATE_LABEL = { + available: "$(smiley) Available", + "deep-focus": "$(eye) Deep Focus", + thinking: "$(bug) Thinking", + frustrated: "$(warning) Frustrated", + away: "$(clock) Away", +}; +// Short text sent to the OLED display (used when a project label is set). +const STATE_TEXT = { + available: "Available", + "deep-focus": "Deep Focus", + thinking: "Thinking...", + frustrated: "Frustrated", + away: "Away", +}; +class ActivityWatcher { + client; + onStateChange; + state = "available"; + // State before debugging started — restored when the debug session ends. + preDebugState = "available"; + debugDepth = 0; + // Baseline error count. We only care about *increases* above this. + errorBaseline = 0; + // Don't auto-update while manual override is active. + manualOverrideUntil = 0; + // Project label prefixed to every display message (e.g. "Alpha"). + projectLabel_ = ""; + idleHandle = null; + focusHandle = null; + refreshHandle = null; + subscriptions = []; + constructor(client, onStateChange) { + this.client = client; + this.onStateChange = onStateChange; + this.subscribe(); + // Seed the baseline so a pre-existing red squiggle sea doesn't + // immediately slam the pet into "frustrated" on extension startup. + this.errorBaseline = this.countErrors(); + // Kick off the idle timer from the start. + this.resetIdleTimer(); + // Periodic heartbeat: re-push the current display text every 30 s so it + // stays visible even if the daemon restarted or another source overwrote it. + this.refreshHandle = setInterval(() => void this.refreshDisplay(), 30_000); + } + // ── Public API ──────────────────────────────────────────────────────── + currentState() { + return this.state; + } + // Called when the user manually sets a status — back off for a while. + notifyManualOverride() { + const mins = this.cfg().manualOverrideMinutes; + this.manualOverrideUntil = Date.now() + mins * 60_000; + } + // True while a manual override is suppressing auto-transitions. + isManualOverrideActive() { + return this.isManualOverride(); + } + // How many minutes remain in the manual override (0 if not active). + manualOverrideRemainingMinutes() { + const remaining = this.manualOverrideUntil - Date.now(); + return remaining > 0 ? Math.ceil(remaining / 60_000) : 0; + } + // Cancel an active manual override and resume auto-tracking immediately. + clearManualOverride() { + this.manualOverrideUntil = 0; + } + // Set (or clear) the project label shown as a prefix on the OLED display. + // Pass an empty string to disable project context. + setProjectLabel(label) { + this.projectLabel_ = label.trim(); + } + projectLabel() { + return this.projectLabel_; + } + dispose() { + this.clearIdleTimer(); + this.clearFocusTimer(); + if (this.refreshHandle !== null) { + clearInterval(this.refreshHandle); + this.refreshHandle = null; + } + for (const s of this.subscriptions) + s.dispose(); + } + // ── VS Code event subscriptions ─────────────────────────────────────── + subscribe() { + this.subscriptions.push( + // Typing / editing + vscode.workspace.onDidChangeTextDocument(() => this.onActivity()), + // Switching files (lighter signal — prevents going `away` when browsing) + vscode.window.onDidChangeActiveTextEditor(() => this.onBrowse()), + // Debug sessions + vscode.debug.onDidStartDebugSession(() => this.onDebugStart()), vscode.debug.onDidTerminateDebugSession(() => this.onDebugEnd()), + // Build / test tasks + vscode.tasks.onDidEndTaskProcess((e) => this.onTaskEnd(e.exitCode ?? 1)), + // Diagnostics (errors / warnings) + vscode.languages.onDidChangeDiagnostics(() => this.onDiagnosticsChanged())); + } + // ── Signal handlers ─────────────────────────────────────────────────── + onActivity() { + this.resetIdleTimer(); + if (this.state === "away") { + // Any keystroke wakes us back up immediately. + void this.transition("available"); + return; + } + if (this.state === "available") { + // Start the focus delay — sustained typing escalates to deep-focus. + this.scheduleFocusTimer(); + } + } + // Browsing files (no edit) — resets idle but doesn't start focus timer. + onBrowse() { + this.resetIdleTimer(); + if (this.state === "away") { + void this.transition("available"); + } + } + onDebugStart() { + this.debugDepth++; + if (this.debugDepth === 1) { + // Save where we were so we can restore it when the session ends. + this.preDebugState = + this.state === "away" ? "available" : this.state; + this.clearFocusTimer(); + void this.transition("thinking"); + } + } + onDebugEnd() { + this.debugDepth = Math.max(0, this.debugDepth - 1); + if (this.debugDepth === 0) { + // Return to where we were before debugging (or available if it was away). + void this.transition(this.preDebugState === "away" ? "available" : this.preDebugState); + } + } + onTaskEnd(exitCode) { + if (exitCode !== 0) { + void this.transition("frustrated"); + } + else if (this.state === "frustrated") { + // Build went green — cheer up. + void this.transition("available"); + } + } + onDiagnosticsChanged() { + const errors = this.countErrors(); + const delta = errors - this.errorBaseline; + if (delta >= this.cfg().errorThreshold) { + void this.transition("frustrated"); + } + else if (errors === 0 && this.state === "frustrated") { + // All errors resolved — clear the baseline and recover. + this.errorBaseline = 0; + void this.transition("available"); + } + // Ratchet the baseline downward so clearing errors is detectable, but + // never upward — we want to catch a spike, not a slow creep. + if (errors < this.errorBaseline) + this.errorBaseline = errors; + } + // ── Timers ───────────────────────────────────────────────────────────── + scheduleFocusTimer() { + // Already scheduled or already in a focused/higher-priority state. + if (this.focusHandle !== null) + return; + if (this.state !== "available") + return; + const ms = this.cfg().focusDelaySeconds * 1000; + this.focusHandle = setTimeout(() => { + this.focusHandle = null; + if (this.state === "available") { + void this.transition("deep-focus"); + } + }, ms); + } + clearFocusTimer() { + if (this.focusHandle !== null) { + clearTimeout(this.focusHandle); + this.focusHandle = null; + } + } + resetIdleTimer() { + this.clearIdleTimer(); + const ms = this.cfg().idleTimeoutMinutes * 60_000; + this.idleHandle = setTimeout(() => { + this.idleHandle = null; + this.clearFocusTimer(); + void this.transition("away"); + }, ms); + } + clearIdleTimer() { + if (this.idleHandle !== null) { + clearTimeout(this.idleHandle); + this.idleHandle = null; + } + } + // ── Helpers ──────────────────────────────────────────────────────────── + countErrors() { + let n = 0; + for (const [, diags] of vscode.languages.getDiagnostics()) { + n += diags.filter((d) => d.severity === vscode.DiagnosticSeverity.Error).length; + } + return n; + } + cfg() { + const c = vscode.workspace.getConfiguration("gochi"); + return { + enabled: c.get("autoMode.enabled", true), + idleTimeoutMinutes: c.get("autoMode.idleTimeoutMinutes", 5), + focusDelaySeconds: c.get("autoMode.focusDelaySeconds", 45), + errorThreshold: c.get("autoMode.errorThreshold", 5), + manualOverrideMinutes: c.get("autoMode.manualOverrideMinutes", 30), + }; + } + isManualOverride() { + return Date.now() < this.manualOverrideUntil; + } + async transition(next) { + if (next === this.state) + return; + if (!this.cfg().enabled) + return; + if (this.isManualOverride()) + return; + this.state = next; + this.onStateChange(next); + await this.client.setStatus(next); + // If a project label is set, overlay the display with "Project | State" + // so the pet always identifies both the active project and the state. + if (this.projectLabel_) { + const text = `${this.projectLabel_} | ${STATE_TEXT[next]}`; + await this.client.setText(text); + } + } + // Re-push the current display text without changing state. Called by the + // heartbeat interval so the label stays visible even if the daemon restarted + // or another source (CLI) temporarily overwrote the display. + // Spotify takes priority: if a track is playing, its code image (or text + // fallback) is re-pushed instead of the project/state label. + async refreshDisplay() { + if (!this.cfg().enabled) + return; + if (this.isManualOverride()) + return; + // Prefer Spotify "now playing" when the user has it running. + const spotify = await this.client.getSpotifyTrack(); + if (spotify) { + if (spotify.image) { + await this.client.setImage(spotify.image); + } + else if (spotify.track) { + await this.client.setText(spotify.track); + } + return; + } + if (!this.projectLabel_) + return; + const text = `${this.projectLabel_} | ${STATE_TEXT[this.state]}`; + await this.client.setText(text); + } +} +exports.ActivityWatcher = ActivityWatcher; +//# sourceMappingURL=watcher.js.map \ No newline at end of file diff --git a/vscode-extension/out/watcher.js.map b/vscode-extension/out/watcher.js.map new file mode 100644 index 0000000..3c17191 --- /dev/null +++ b/vscode-extension/out/watcher.js.map @@ -0,0 +1 @@ +{"version":3,"file":"watcher.js","sourceRoot":"","sources":["../src/watcher.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json new file mode 100644 index 0000000..b68209c --- /dev/null +++ b/vscode-extension/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "gochi-activity", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gochi-activity", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000..20bf0cb --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,76 @@ +{ + "name": "gochi-activity", + "displayName": "Gochi Activity Watcher", + "description": "Auto-updates your Gochi desk pet based on VS Code activity — typing, debugging, errors, idle time.", + "version": "0.1.0", + "publisher": "gochi", + "engines": { "vscode": "^1.85.0" }, + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "gochi.toggleAutoMode", + "title": "Gochi: Toggle Auto Mode" + }, + { + "command": "gochi.setStatus", + "title": "Gochi: Set Status" + } + ], + "configuration": { + "title": "Gochi Activity Watcher", + "properties": { + "gochi.autoMode.enabled": { + "type": "boolean", + "default": true, + "description": "Automatically update the pet's status based on VS Code activity." + }, + "gochi.daemonUrl": { + "type": "string", + "default": "http://localhost:7474", + "description": "URL of the Gochi HTTP frontend (started by `gochi server enable`)." + }, + "gochi.autoMode.idleTimeoutMinutes": { + "type": "number", + "default": 5, + "minimum": 1, + "description": "Minutes of no activity before switching to 'away'." + }, + "gochi.autoMode.focusDelaySeconds": { + "type": "number", + "default": 45, + "minimum": 10, + "description": "Seconds of sustained typing before switching to 'deep-focus'." + }, + "gochi.autoMode.errorThreshold": { + "type": "number", + "default": 5, + "minimum": 1, + "description": "Number of new errors (vs baseline) that triggers 'frustrated'." + }, + "gochi.autoMode.manualOverrideMinutes": { + "type": "number", + "default": 30, + "minimum": 1, + "description": "Minutes to suppress auto-updates after a manual `gochi status` call." + }, + "gochi.projectLabel": { + "type": "string", + "default": "", + "description": "Project name shown on the display as 'ProjectName | State' (e.g. 'Alpha'). Leave empty to use the workspace folder name automatically." + } + } + } + }, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/vscode": "^1.85.0", + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 0000000..e05fd5c --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,216 @@ +// extension.ts — VS Code extension entry point for Gochi Activity Watcher. +// +// Activates on startup (onStartupFinished) and: +// 1. Creates an HTTP client for the Gochi daemon's HTTP frontend. +// 2. Starts the ActivityWatcher (event subscriptions + state machine). +// 3. Shows a status-bar item reflecting the current auto-detected state. +// 4. Registers two commands: +// gochi.toggleAutoMode — enable / disable auto updates +// gochi.setStatus — manually pick a status (pauses auto for 30 min) + +import * as vscode from "vscode"; + +import { GochiClient } from "./gochi-client.js"; +import { ActivityWatcher, STATE_LABEL, type WatcherState } from "./watcher.js"; + +// All available status names (mirrors cli/src/status.ts — kept in sync manually). +const STATUS_NAMES: Array<{ label: string; name: string; description: string }> = + [ + { name: "available", label: "Available", description: "Free to chat and collaborate" }, + { name: "busy", label: "Busy", description: "Working — keep interruptions to a minimum" }, + { name: "in-meeting", label: "In Meeting", description: "Currently in a meeting" }, + { name: "deep-focus", label: "Deep Focus", description: "Flow state — please don't interrupt" }, + { name: "frustrated", label: "Frustrated", description: "Hitting blockers or feeling stressed" }, + { name: "on-break", label: "On Break", description: "Coffee or lunch — back soon" }, + { name: "away", label: "Away", description: "Stepped away from my desk" }, + { name: "do-not-disturb", label: "Do Not Disturb", description: "Absolute focus — hold all messages" }, + { name: "reviewing", label: "Reviewing", description: "In a code or document review" }, + { name: "thinking", label: "Thinking", description: "Problem-solving mode" }, + ]; + +export function activate(context: vscode.ExtensionContext): void { + const url = vscode.workspace + .getConfiguration("gochi") + .get("daemonUrl", "http://localhost:7474"); + + const client = new GochiClient(url); + + // Resolve the project label: explicit setting takes priority, then the + // workspace folder name, then empty (no overlay). + function resolveProjectLabel(): string { + const cfg = vscode.workspace + .getConfiguration("gochi") + .get("projectLabel", ""); + if (cfg.trim()) return cfg.trim(); + const folders = vscode.workspace.workspaceFolders; + if (folders && folders.length > 0) return folders[0].name; + return ""; + } + + // Status-bar item — click it to quickly pick a status. + const statusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 50, + ); + statusBar.command = "gochi.setStatus"; + statusBar.tooltip = "Gochi desk pet status (click to set manually)"; + context.subscriptions.push(statusBar); + + function updateStatusBar(state: WatcherState): void { + const enabled = vscode.workspace + .getConfiguration("gochi") + .get("autoMode.enabled", true); + if (!enabled) { + statusBar.text = "$(circle-slash) Gochi (paused)"; + statusBar.tooltip = "Auto-mode disabled — click to set status"; + } else if (watcher.isManualOverrideActive()) { + const mins = watcher.manualOverrideRemainingMinutes(); + statusBar.text = `$(lock) ${STATE_LABEL[state].replace(/^\$\([^)]+\) /, "")} (manual, ${mins}m)`; + statusBar.tooltip = "Manual override active — click to resume auto-mode or pick a new status"; + } else { + statusBar.text = STATE_LABEL[state]; + statusBar.tooltip = "Gochi auto-mode active — click to set status manually"; + } + statusBar.show(); + } + + const watcher = new ActivityWatcher(client, (state) => { + updateStatusBar(state); + }); + context.subscriptions.push(watcher); + + // Seed project label from config / workspace folder. + watcher.setProjectLabel(resolveProjectLabel()); + + // Show initial state. + updateStatusBar(watcher.currentState()); + + // Ping the daemon once so the user sees a warning if it isn't reachable. + void client.health().then((h) => { + if (h === null) { + vscode.window.showWarningMessage( + "Gochi: cannot reach the HTTP frontend. Run `gochi server enable` in your terminal.", + "Dismiss", + ); + } else if (!h.connected) { + vscode.window.showInformationMessage( + "Gochi: daemon is running but the device is not connected.", + ); + } + }); + + // ── Commands ────────────────────────────────────────────────────────── + + context.subscriptions.push( + vscode.commands.registerCommand("gochi.toggleAutoMode", () => { + const cfg = vscode.workspace.getConfiguration("gochi"); + const current = cfg.get("autoMode.enabled", true); + void cfg + .update("autoMode.enabled", !current, vscode.ConfigurationTarget.Global) + .then(() => { + const nowEnabled = !current; + updateStatusBar(watcher.currentState()); + vscode.window.showInformationMessage( + nowEnabled + ? "Gochi auto-mode enabled — the pet now mirrors your activity." + : "Gochi auto-mode paused — use `Gochi: Set Status` to update manually.", + ); + }); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("gochi.setStatus", async () => { + // If override is active, offer a quick "resume" shortcut at the top. + if (watcher.isManualOverrideActive()) { + const mins = watcher.manualOverrideRemainingMinutes(); + const resume = await vscode.window.showQuickPick( + [ + { label: "$(play) Resume auto-mode", description: `Cancel the ${mins}m manual lock`, value: "__resume__" }, + { label: "$(edit) Set a different status…", description: "Pick a new status (resets the timer)", value: "__pick__" }, + ], + { title: `Gochi — manual override active (${mins}m left)`, placeHolder: "" }, + ); + if (!resume) return; + if (resume.value === "__resume__") { + watcher.clearManualOverride(); + updateStatusBar(watcher.currentState()); + vscode.window.showInformationMessage("Gochi: auto-mode resumed."); + return; + } + // fall through to the full picker below + } + + const picked = await vscode.window.showQuickPick( + STATUS_NAMES.map((s) => ({ + label: s.label, + description: s.description, + detail: s.name, // shown as small text, also used as the API slug + })), + { + title: "Set Gochi Status", + placeHolder: "Choose your current availability…", + matchOnDescription: true, + }, + ); + + if (!picked) return; // user cancelled + + const ok = await client.setStatus(picked.detail!); + if (ok) { + watcher.notifyManualOverride(); + // If a project label is active, follow the status with a project overlay. + const label = watcher.projectLabel(); + if (label) { + await client.setText(`${label} | ${picked.label}`); + } + statusBar.text = `$(check) ${picked.label}`; + const mins = vscode.workspace + .getConfiguration("gochi") + .get("autoMode.manualOverrideMinutes", 30); + setTimeout(() => updateStatusBar(watcher.currentState()), 2000); + const choice = await vscode.window.showInformationMessage( + `Gochi: status set to "${picked.label}". Auto-mode paused for ${mins} min.`, + "Resume Auto", + ); + if (choice === "Resume Auto") { + watcher.clearManualOverride(); + updateStatusBar(watcher.currentState()); + } + } else { + vscode.window.showErrorMessage( + "Gochi: failed to set status — is the HTTP frontend running? (`gochi server enable`)", + ); + } + }), + ); + + // React to config changes (e.g. user toggling autoMode from settings UI). + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("gochi.autoMode.enabled")) { + updateStatusBar(watcher.currentState()); + } + if (e.affectsConfiguration("gochi.projectLabel")) { + watcher.setProjectLabel(resolveProjectLabel()); + } + if (e.affectsConfiguration("gochi.daemonUrl")) { + // Restart with the new URL requires a reload — prompt the user. + void vscode.window + .showInformationMessage( + "Gochi: daemon URL changed. Reload the window to apply.", + "Reload", + ) + .then((choice) => { + if (choice === "Reload") { + void vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); + } + }), + ); +} + +export function deactivate(): void { + // Subscriptions and watcher are cleaned up via context.subscriptions. +} diff --git a/vscode-extension/src/gochi-client.ts b/vscode-extension/src/gochi-client.ts new file mode 100644 index 0000000..e2ca828 --- /dev/null +++ b/vscode-extension/src/gochi-client.ts @@ -0,0 +1,98 @@ +// gochi-client.ts — thin HTTP client for the Gochi HTTP frontend. +// Talks to the TCP server started by `gochi server enable` (default :7474). +// All calls are fire-and-forget safe: they swallow network errors so a +// disconnected device or disabled frontend never crashes the extension. + +export interface HealthResult { + ok: boolean; + connected: boolean; + port?: string; + version?: string; +} + +export class GochiClient { + constructor(private baseUrl: string) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + async health(): Promise { + try { + const res = await fetch(`${this.baseUrl}/health`, { + signal: AbortSignal.timeout(2000), + }); + return (await res.json()) as HealthResult; + } catch { + return null; + } + } + + // Apply a named availability-status profile (face + mood + optional text). + async setStatus(name: string): Promise { + try { + const res = await fetch(`${this.baseUrl}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + signal: AbortSignal.timeout(3000), + }); + const data = (await res.json()) as { ok: boolean }; + return data.ok === true; + } catch { + return false; + } + } + + // Show arbitrary scrolling text on the display. + async setText(text: string): Promise { + try { + const res = await fetch(`${this.baseUrl}/text`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + signal: AbortSignal.timeout(3000), + }); + const data = (await res.json()) as { ok: boolean }; + return data.ok === true; + } catch { + return false; + } + } + + // Send a 128×64 1bpp frame (base64) to the display via SHOW image. + async setImage(data: string): Promise { + try { + const res = await fetch(`${this.baseUrl}/image`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data }), + signal: AbortSignal.timeout(3000), + }); + const json = (await res.json()) as { ok: boolean }; + return json.ok === true; + } catch { + return false; + } + } + + // Return the current Spotify "now playing" state stored in the daemon, + // or null if nothing is playing / Spotify watch is not running. + async getSpotifyTrack(): Promise<{ track: string | null; image: string | null } | null> { + try { + const res = await fetch(`${this.baseUrl}/spotify/track`, { + signal: AbortSignal.timeout(2000), + }); + const data = (await res.json()) as { + ok: boolean; + track?: string | null; + image?: string | null; + }; + if (!data.ok) return null; + return { + track: typeof data.track === "string" ? data.track : null, + image: typeof data.image === "string" ? data.image : null, + }; + } catch { + return null; + } + } +} diff --git a/vscode-extension/src/watcher.ts b/vscode-extension/src/watcher.ts new file mode 100644 index 0000000..fe3db90 --- /dev/null +++ b/vscode-extension/src/watcher.ts @@ -0,0 +1,322 @@ +// watcher.ts — VS Code activity → Gochi status state machine. +// +// Detected states and what triggers them: +// +// available — default; also restored after debugging / errors clear +// deep-focus — sustained typing for N seconds (no debug, no spike) +// thinking — a debug session is active +// frustrated — N new errors vs baseline, OR a task/build exits non-zero +// away — no keyboard / editor activity for N minutes +// +// Priority (highest wins when multiple signals fire at once): +// thinking > frustrated > deep-focus > available > away +// +// Manual override: if the user calls `gochi status` from the CLI or the +// command palette, the watcher backs off for `manualOverrideMinutes` so +// it doesn't immediately override what was chosen. + +import * as vscode from "vscode"; +import type { GochiClient } from "./gochi-client.js"; + +export type WatcherState = + | "available" + | "deep-focus" + | "thinking" + | "frustrated" + | "away"; + +// Human-readable label shown in the status bar. +export const STATE_LABEL: Record = { + available: "$(smiley) Available", + "deep-focus": "$(eye) Deep Focus", + thinking: "$(bug) Thinking", + frustrated: "$(warning) Frustrated", + away: "$(clock) Away", +}; + +// Short text sent to the OLED display (used when a project label is set). +const STATE_TEXT: Record = { + available: "Available", + "deep-focus": "Deep Focus", + thinking: "Thinking...", + frustrated: "Frustrated", + away: "Away", +}; + +export class ActivityWatcher implements vscode.Disposable { + private state: WatcherState = "available"; + // State before debugging started — restored when the debug session ends. + private preDebugState: WatcherState = "available"; + private debugDepth = 0; + // Baseline error count. We only care about *increases* above this. + private errorBaseline = 0; + // Don't auto-update while manual override is active. + private manualOverrideUntil = 0; + // Project label prefixed to every display message (e.g. "Alpha"). + private projectLabel_ = ""; + + private idleHandle: ReturnType | null = null; + private focusHandle: ReturnType | null = null; + private refreshHandle: ReturnType | null = null; + private subscriptions: vscode.Disposable[] = []; + + constructor( + private readonly client: GochiClient, + private readonly onStateChange: (state: WatcherState) => void, + ) { + this.subscribe(); + // Seed the baseline so a pre-existing red squiggle sea doesn't + // immediately slam the pet into "frustrated" on extension startup. + this.errorBaseline = this.countErrors(); + // Kick off the idle timer from the start. + this.resetIdleTimer(); + // Periodic heartbeat: re-push the current display text every 30 s so it + // stays visible even if the daemon restarted or another source overwrote it. + this.refreshHandle = setInterval(() => void this.refreshDisplay(), 30_000); + } + + // ── Public API ──────────────────────────────────────────────────────── + + currentState(): WatcherState { + return this.state; + } + + // Called when the user manually sets a status — back off for a while. + notifyManualOverride(): void { + const mins = this.cfg().manualOverrideMinutes; + this.manualOverrideUntil = Date.now() + mins * 60_000; + } + + // True while a manual override is suppressing auto-transitions. + isManualOverrideActive(): boolean { + return this.isManualOverride(); + } + + // How many minutes remain in the manual override (0 if not active). + manualOverrideRemainingMinutes(): number { + const remaining = this.manualOverrideUntil - Date.now(); + return remaining > 0 ? Math.ceil(remaining / 60_000) : 0; + } + + // Cancel an active manual override and resume auto-tracking immediately. + clearManualOverride(): void { + this.manualOverrideUntil = 0; + } + + // Set (or clear) the project label shown as a prefix on the OLED display. + // Pass an empty string to disable project context. + setProjectLabel(label: string): void { + this.projectLabel_ = label.trim(); + } + + projectLabel(): string { + return this.projectLabel_; + } + + dispose(): void { + this.clearIdleTimer(); + this.clearFocusTimer(); + if (this.refreshHandle !== null) { + clearInterval(this.refreshHandle); + this.refreshHandle = null; + } + for (const s of this.subscriptions) s.dispose(); + } + + // ── VS Code event subscriptions ─────────────────────────────────────── + + private subscribe(): void { + this.subscriptions.push( + // Typing / editing + vscode.workspace.onDidChangeTextDocument(() => this.onActivity()), + // Switching files (lighter signal — prevents going `away` when browsing) + vscode.window.onDidChangeActiveTextEditor(() => this.onBrowse()), + // Debug sessions + vscode.debug.onDidStartDebugSession(() => this.onDebugStart()), + vscode.debug.onDidTerminateDebugSession(() => this.onDebugEnd()), + // Build / test tasks + vscode.tasks.onDidEndTaskProcess((e) => this.onTaskEnd(e.exitCode ?? 1)), + // Diagnostics (errors / warnings) + vscode.languages.onDidChangeDiagnostics(() => this.onDiagnosticsChanged()), + ); + } + + // ── Signal handlers ─────────────────────────────────────────────────── + + private onActivity(): void { + this.resetIdleTimer(); + if (this.state === "away") { + // Any keystroke wakes us back up immediately. + void this.transition("available"); + return; + } + if (this.state === "available") { + // Start the focus delay — sustained typing escalates to deep-focus. + this.scheduleFocusTimer(); + } + } + + // Browsing files (no edit) — resets idle but doesn't start focus timer. + private onBrowse(): void { + this.resetIdleTimer(); + if (this.state === "away") { + void this.transition("available"); + } + } + + private onDebugStart(): void { + this.debugDepth++; + if (this.debugDepth === 1) { + // Save where we were so we can restore it when the session ends. + this.preDebugState = + this.state === "away" ? "available" : this.state; + this.clearFocusTimer(); + void this.transition("thinking"); + } + } + + private onDebugEnd(): void { + this.debugDepth = Math.max(0, this.debugDepth - 1); + if (this.debugDepth === 0) { + // Return to where we were before debugging (or available if it was away). + void this.transition( + this.preDebugState === "away" ? "available" : this.preDebugState, + ); + } + } + + private onTaskEnd(exitCode: number): void { + if (exitCode !== 0) { + void this.transition("frustrated"); + } else if (this.state === "frustrated") { + // Build went green — cheer up. + void this.transition("available"); + } + } + + private onDiagnosticsChanged(): void { + const errors = this.countErrors(); + const delta = errors - this.errorBaseline; + + if (delta >= this.cfg().errorThreshold) { + void this.transition("frustrated"); + } else if (errors === 0 && this.state === "frustrated") { + // All errors resolved — clear the baseline and recover. + this.errorBaseline = 0; + void this.transition("available"); + } + + // Ratchet the baseline downward so clearing errors is detectable, but + // never upward — we want to catch a spike, not a slow creep. + if (errors < this.errorBaseline) this.errorBaseline = errors; + } + + // ── Timers ───────────────────────────────────────────────────────────── + + private scheduleFocusTimer(): void { + // Already scheduled or already in a focused/higher-priority state. + if (this.focusHandle !== null) return; + if (this.state !== "available") return; + + const ms = this.cfg().focusDelaySeconds * 1000; + this.focusHandle = setTimeout(() => { + this.focusHandle = null; + if (this.state === "available") { + void this.transition("deep-focus"); + } + }, ms); + } + + private clearFocusTimer(): void { + if (this.focusHandle !== null) { + clearTimeout(this.focusHandle); + this.focusHandle = null; + } + } + + private resetIdleTimer(): void { + this.clearIdleTimer(); + const ms = this.cfg().idleTimeoutMinutes * 60_000; + this.idleHandle = setTimeout(() => { + this.idleHandle = null; + this.clearFocusTimer(); + void this.transition("away"); + }, ms); + } + + private clearIdleTimer(): void { + if (this.idleHandle !== null) { + clearTimeout(this.idleHandle); + this.idleHandle = null; + } + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private countErrors(): number { + let n = 0; + for (const [, diags] of vscode.languages.getDiagnostics()) { + n += diags.filter( + (d) => d.severity === vscode.DiagnosticSeverity.Error, + ).length; + } + return n; + } + + private cfg() { + const c = vscode.workspace.getConfiguration("gochi"); + return { + enabled: c.get("autoMode.enabled", true), + idleTimeoutMinutes: c.get("autoMode.idleTimeoutMinutes", 5), + focusDelaySeconds: c.get("autoMode.focusDelaySeconds", 45), + errorThreshold: c.get("autoMode.errorThreshold", 5), + manualOverrideMinutes: c.get("autoMode.manualOverrideMinutes", 30), + }; + } + + private isManualOverride(): boolean { + return Date.now() < this.manualOverrideUntil; + } + + private async transition(next: WatcherState): Promise { + if (next === this.state) return; + if (!this.cfg().enabled) return; + if (this.isManualOverride()) return; + + this.state = next; + this.onStateChange(next); + await this.client.setStatus(next); + + // If a project label is set, overlay the display with "Project | State" + // so the pet always identifies both the active project and the state. + if (this.projectLabel_) { + const text = `${this.projectLabel_} | ${STATE_TEXT[next]}`; + await this.client.setText(text); + } + } + + // Re-push the current display text without changing state. Called by the + // heartbeat interval so the label stays visible even if the daemon restarted + // or another source (CLI) temporarily overwrote the display. + // Spotify takes priority: if a track is playing, its code image (or text + // fallback) is re-pushed instead of the project/state label. + private async refreshDisplay(): Promise { + if (!this.cfg().enabled) return; + if (this.isManualOverride()) return; + + // Prefer Spotify "now playing" when the user has it running. + const spotify = await this.client.getSpotifyTrack(); + if (spotify) { + if (spotify.image) { + await this.client.setImage(spotify.image); + } else if (spotify.track) { + await this.client.setText(spotify.track); + } + return; + } + + if (!this.projectLabel_) return; + const text = `${this.projectLabel_} | ${STATE_TEXT[this.state]}`; + await this.client.setText(text); + } +} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 0000000..9f35eac --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "out", + "rootDir": "src", + "sourceMap": true, + "strict": true, + "moduleResolution": "node16", + "skipLibCheck": true + }, + "exclude": ["node_modules", "out"] +} diff --git a/web-ble-controller.html b/web-ble-controller.html new file mode 100644 index 0000000..01c995e --- /dev/null +++ b/web-ble-controller.html @@ -0,0 +1,467 @@ + + + + + + Gochi BLE Controller + + + +
+

🎮 Gochi BLE Controller

+

Web Bluetooth Controller for your ESP32-C3 Tamagotchi

+ +
+ + Disconnected +
+ +
+ ✓ Web Bluetooth supported +
+ + + + + +
+ + + +