From 1996a123d919432a761a6b714ad4d94102068100 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Mon, 17 Aug 2026 13:19:36 -0400 Subject: [PATCH] feat: add tactility controls and notes CRUD --- README.md | 52 ++++++++++---- src/reyna_cli/cli.py | 31 ++++++++- src/reyna_cli/config.py | 30 ++++++++- src/reyna_cli/notes_direct.py | 74 ++++++++++++++++++++ src/reyna_cli/tactility.py | 123 ++++++++++++++++++++++++++++++++++ tests/test_notes_deferred.py | 21 +++++- tests/test_tactility.py | 113 +++++++++++++++++++++++++++++++ 7 files changed, 426 insertions(+), 18 deletions(-) create mode 100644 src/reyna_cli/tactility.py create mode 100644 tests/test_tactility.py diff --git a/README.md b/README.md index dfae632..0d1f822 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,15 @@ On-demand CLI façade for Reyna family MCP services and LAN devices. The goal is ## Phase 2 scope -- **Devices parent command**: All local devices are now under `reyna-cli devices`. +- **Devices parent command**: Non-Tactility local devices remain under `reyna-cli devices`; ESP32 boards use the dedicated `reyna-cli tactility` command group. - `reyna-cli devices screen ...` (ESP32 screen) - `reyna-cli devices laptop ...` (personal laptop MCP screen) - `reyna-cli devices computer ...` (this computer's desktop companion client) - `reyna-cli devices iphone ...` (iPhone app) - - `reyna-cli devices arm ...` (Robot arm) -- **Robot Arm**: Dynamic tool resolution for `state`, `home`, `wave`, `battery`. +- **Tactility boards**: mDNS-first discovery with reserved-IP fallback, plus direct web API access for sysinfo, apps, app installation/run, and filesystem operations. - **Immich**: On-demand MCP bridge at `http://127.0.0.1:8626/mcp`. - **MongoDB**: On-demand MCP bridge at `http://127.0.0.1:8630/mcp`. -- **Top-level aliases**: `reyna-cli screen`, `reyna-cli laptop`, `reyna-cli iphone`, `reyna-cli arm` still work for compatibility. +- **Top-level aliases**: `reyna-cli screen`, `reyna-cli laptop`, and `reyna-cli iphone` still work for compatibility. ## Install / run @@ -22,6 +21,23 @@ uv sync uv run reyna-cli doctor ``` +## Signed Python launcher (macOS permissions) + +The manually built, signed **Reyna CLI.app** owns the macOS privacy identity. Use the `signed` wrapper to launch the current Python CLI through that app: + +```bash +# Optional: use a different active checkout or interpreter without rebuilding the app. +cat > ~/.reyna-cli.env <<'EOF' +REYNA_CLI_DIR=/Users/adolforeyna/Projects/platform/reyna-cli +REYNA_CLI_PYTHON=/Users/adolforeyna/Projects/platform/reyna-cli/.venv/bin/python +EOF + +uv run reyna-cli signed doctor +uv run reyna-cli signed local-services speech transcribe-file ./sample.wav --json +``` + +The signed launcher reads `~/.reyna-cli.env`, then `~/.config/reyna-cli/env`; `REYNA_CLI_DIR` and `REYNA_CLI_PYTHON` environment variables override those values. Python-only edits take effect on the next `signed` run—**do not rebuild or re-sign the app for those edits**. Changes to Swift sources, `Info.plist`, or app signing require a new manual signed Xcode build before they can be used by `signed`. + ## Device discovery ```bash @@ -29,6 +45,25 @@ uv run reyna-cli devices list --json uv run reyna-cli devices ping esp32_screen --json uv run reyna-cli devices tools esp32_screen --json uv run reyna-cli devices describe esp32_screen --for-hermes + +# Tactility fleet discovery; offline boards remain visible in the JSON report +uv run reyna-cli tactility discover --json +uv run reyna-cli tactility sysinfo kidsos1 --json +uv run reyna-cli tactility apps kidsos1 --json +uv run reyna-cli tactility tools Grace --json +uv run reyna-cli tactility describe Grace --json +uv run reyna-cli tactility call Grace get_screenshot --args '{}' --json +uv run reyna-cli tactility call Grace draw_color_bmp --args '{"bmp_base64":"...","x":0,"y":0}' --json +uv run reyna-cli tactility call Grace play_tone --args '{"frequency":440,"duration_ms":500,"volume":40}' --json +uv run reyna-cli tactility call Grace get_sensors --args '{}' --json +uv run reyna-cli tactility install kidsos1 ./build/my.app --json +uv run reyna-cli tactility run kidsos1 one.tactility.myapp --json +uv run reyna-cli tactility report kidsos1 --json +uv run reyna-cli tactility screen clear kidsos1 --width 320 --height 240 --json +uv run reyna-cli tactility screen text kidsos1 "Grace\\nReady" --json +uv run reyna-cli tactility screen text kidsos1 "Layer intentionally" --no-clear-first --json +uv run reyna-cli tactility fs list kidsos1 --path /sdcard --json +uv run reyna-cli tactility fs upload kidsos1 ./manifest.json /sdcard/manifest.json --json ``` ## Generic MCP calls @@ -37,15 +72,6 @@ uv run reyna-cli devices describe esp32_screen --for-hermes uv run reyna-cli devices call esp32_screen draw_text --args '{"text":"Hello","x":10,"y":20,"size":2}' --json ``` -## Robot Arm - -```bash -uv run reyna-cli devices arm state -uv run reyna-cli devices arm home -uv run reyna-cli devices arm wave -uv run reyna-cli devices arm battery --json -``` - ## ESP32 screen, laptop screen, this computer & iPhone ```bash diff --git a/src/reyna_cli/cli.py b/src/reyna_cli/cli.py index 9315d33..75750f0 100644 --- a/src/reyna_cli/cli.py +++ b/src/reyna_cli/cli.py @@ -26,7 +26,7 @@ from reyna_cli.gitea_direct import GiteaClient, load_credentials from reyna_cli.immich import ImmichClient from reyna_cli.mcp import MCPClient from reyna_cli.mongo_direct import MongoDirectClient -from reyna_cli.notes_direct import NotesAutomationError, create_note, list_notes, read_note +from reyna_cli.notes_direct import NotesAutomationError, create_note, delete_note, list_notes, read_note, update_note from reyna_cli.privacy_client import default_socket_path as privacy_default_socket_path from reyna_cli.privacy_host import native_calendar_list, privacy_host_status_payload from reyna_cli.remarkable import LISTENER_LABEL, listen_forever, listener_service_action, listener_service_status, sync_once @@ -145,6 +145,35 @@ def notes_create( fail(str(exc), json_output) +@notes_app.command("edit") +def notes_edit( + note_id: str, + title: Optional[str] = typer.Option(None, "--title"), + body: Optional[str] = typer.Option(None, "--body"), + json_output: bool = typer.Option(False, "--json"), +): + """Replace the title and/or body of one Apple Note by ID.""" + try: + emit({"ok": True, "source": "direct_apple_notes", "note": update_note(note_id, title=title, body=body)}, json_output) + except (NotesAutomationError, ValueError) as exc: + fail(str(exc), json_output) + + +@notes_app.command("delete") +def notes_delete( + note_id: str, + force: bool = typer.Option(False, "--force", help="Required: permanently delete the note."), + json_output: bool = typer.Option(False, "--json"), +): + """Permanently delete one Apple Note by ID; requires explicit --force.""" + if not force: + fail("Deletion requires --force", json_output) + try: + emit({"ok": True, "source": "direct_apple_notes", "note": delete_note(note_id)}, json_output) + except (NotesAutomationError, ValueError) as exc: + fail(str(exc), json_output) + + def scrub_sensitive(value: Any) -> Any: if isinstance(value, dict): cleaned: Dict[str, Any] = {} diff --git a/src/reyna_cli/config.py b/src/reyna_cli/config.py index 68f506e..2cf7a1b 100644 --- a/src/reyna_cli/config.py +++ b/src/reyna_cli/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import socket from pathlib import Path from typing import Any, Dict, List, Optional @@ -36,6 +37,9 @@ class Device(BaseModel): urls.append(url) return urls + def resolved_host(self) -> str: + return resolve_device_host(self) + def public_dict(self) -> Dict[str, Any]: return { "id": self.id, @@ -108,13 +112,12 @@ def load_registry(path: Optional[Path] = None) -> Registry: def get_device(name: str, registry: Optional[Registry] = None) -> Optional[Device]: registry = registry or load_registry() - normalized = name.strip() + normalized = name.strip().casefold() shorthand = { "screen": "esp32_screen", "esp32": "esp32_screen", "iphone": "iphone_mcp", "phone": "iphone_mcp", - "arm": "robot_arm", "laptop": "personal_laptop_screen", "laptop_screen": "personal_laptop_screen", "personal_laptop": "personal_laptop_screen", @@ -123,11 +126,32 @@ def get_device(name: str, registry: Optional[Registry] = None) -> Optional[Devic "local": "local_desktop_screen", } normalized = shorthand.get(normalized, normalized) + aliases = {str(key).casefold(): value for key, value in registry.aliases.items()} + normalized = aliases.get(normalized, normalized) for device in registry.devices: - if normalized in {device.id, device.host, device.display_name}: + candidates = {device.id.casefold(), device.host.casefold(), device.display_name.casefold()} + if normalized in candidates: + return device + display = device.display_name.casefold() + if display.startswith(normalized + " ") or display.startswith(normalized + "'s"): return device return None +def resolve_device_host(device: Device) -> str: + """Resolve mDNS when available, retaining the registry IP for offline boards.""" + if device.host: + try: + return socket.gethostbyname(device.host) + except OSError: + pass + return device.reserved_ip or device.host + + +def is_tactility_device(device: Device) -> bool: + value = f"{device.id} {device.type} {' '.join(device.capabilities)}".lower() + return "tactility" in value or device.id.startswith("kidsos_") or device.id == "esp32_screen" + + def cache_path_for(device_id: str) -> Path: return CACHE_DIR / "devices" / f"{device_id}-tools.json" diff --git a/src/reyna_cli/notes_direct.py b/src/reyna_cli/notes_direct.py index 745d765..c736ecb 100644 --- a/src/reyna_cli/notes_direct.py +++ b/src/reyna_cli/notes_direct.py @@ -105,6 +105,53 @@ _NOTES_CREATE_SCRIPT = r'''function run(argv) { }''' +_NOTES_UPDATE_SCRIPT = r'''function run(argv) { + const input = JSON.parse(argv[0]); + const app = Application("/System/Applications/Notes.app"); + const escapeHtml = value => String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + const accounts = app.accounts(); + for (let a = 0; a < accounts.length; a++) { + const folders = accounts[a].folders(); + for (let f = 0; f < folders.length; f++) { + const notes = folders[f].notes(); + for (let n = 0; n < notes.length; n++) { + const note = notes[n]; + if (String(note.id()) !== input.id) continue; + const oldTitle = String(note.name()); + const oldText = String(note.plaintext()); + const fallbackBody = oldText.startsWith(oldTitle) ? oldText.slice(oldTitle.length).replace(/^\\n+/, "") : oldText; + const title = input.title === null ? oldTitle : input.title; + const body = input.body === null ? fallbackBody : input.body; + note.body = "

" + escapeHtml(title) + "

" + escapeHtml(body).replace(/\\n/g, "
") + "
"; + return JSON.stringify({id: String(note.id()), title: String(note.name()), folder: String(folders[f].name())}); + } + } + } + throw new Error("Note not found"); +}''' + + +_NOTES_DELETE_SCRIPT = r'''function run(argv) { + const input = JSON.parse(argv[0]); + const app = Application("/System/Applications/Notes.app"); + const accounts = app.accounts(); + for (let a = 0; a < accounts.length; a++) { + const folders = accounts[a].folders(); + for (let f = 0; f < folders.length; f++) { + const notes = folders[f].notes(); + for (let n = 0; n < notes.length; n++) { + const note = notes[n]; + if (String(note.id()) !== input.id) continue; + const id = String(note.id()); + note.delete(); + return JSON.stringify({id: id, deleted: true}); + } + } + } + throw new Error("Note not found"); +}''' + + def _run_jxa(script: str, payload: dict[str, Any], *, runner: Runner = subprocess.run) -> Any: arguments: Sequence[str] = [ "/usr/bin/osascript", @@ -178,3 +225,30 @@ def create_note( if not isinstance(result, dict): raise NotesAutomationError("Apple Notes create response was not an object") return result + + +def update_note( + note_id: str, + *, + title: Optional[str] = None, + body: Optional[str] = None, + runner: Runner = subprocess.run, +) -> dict[str, Any]: + note_id = _bounded_text(note_id, "note id", 1024) + if title is None and body is None: + raise ValueError("Specify title or body to update") + if title is not None: + title = _bounded_text(title, "title", 500) + if body is not None and len(body) > 100_000: + raise ValueError("body exceeds maximum length 100000") + result = _run_jxa(_NOTES_UPDATE_SCRIPT, {"id": note_id, "title": title, "body": body}, runner=runner) + if not isinstance(result, dict): + raise NotesAutomationError("Apple Notes update response was not an object") + return result + + +def delete_note(note_id: str, *, runner: Runner = subprocess.run) -> dict[str, Any]: + result = _run_jxa(_NOTES_DELETE_SCRIPT, {"id": _bounded_text(note_id, "note id", 1024)}, runner=runner) + if not isinstance(result, dict) or result.get("deleted") is not True: + raise NotesAutomationError("Apple Notes delete response was not confirmed") + return result diff --git a/src/reyna_cli/tactility.py b/src/reyna_cli/tactility.py new file mode 100644 index 0000000..9912f70 --- /dev/null +++ b/src/reyna_cli/tactility.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional +from urllib.parse import quote + +import httpx +from PIL import Image, ImageDraw, ImageFont + + +class TactilityClient: + """Small direct client for the Tactility firmware HTTP API.""" + + def __init__(self, base_url: str, timeout: float = 30.0, transport: Optional[httpx.BaseTransport] = None): + self.base_url = base_url.rstrip("/") + self.client = httpx.Client(timeout=timeout, transport=transport, follow_redirects=True) + + def _response(self, response: httpx.Response) -> Any: + response.raise_for_status() + if not response.content: + return {"ok": True, "status_code": response.status_code} + content_type = response.headers.get("content-type", "") + if "json" in content_type: + return response.json() + try: + return response.json() + except ValueError: + return {"ok": True, "status_code": response.status_code, "response": response.text} + + def get(self, path: str, **kwargs: Any) -> Any: + return self._response(self.client.get(f"{self.base_url}{path}", **kwargs)) + + def post(self, path: str, **kwargs: Any) -> Any: + return self._response(self.client.post(f"{self.base_url}{path}", **kwargs)) + + def sysinfo(self) -> Any: + return self.get("/api/sysinfo") + + def apps(self) -> Any: + return self.get("/api/apps") + + def install_app(self, app_path: Path) -> Any: + with app_path.open("rb") as handle: + response = self.client.put( + f"{self.base_url}/api/apps/install", + files={"file": (app_path.name, handle, "application/octet-stream")}, + ) + return self._response(response) + + def run_app(self, app_id: str) -> Any: + return self.post("/api/apps/run", params={"id": app_id}) + + def fs_list(self, path: str = "/") -> Any: + return self.get("/fs/list", params={"path": path}) + + def fs_mkdir(self, path: str) -> Any: + return self.post("/fs/mkdir", params={"path": path}) + + def fs_upload(self, local_path: Path, remote_path: str) -> Any: + data = local_path.read_bytes() + return self.post( + "/fs/upload", + params={"path": remote_path}, + content=data, + headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(data))}, + ) + + def fs_download(self, remote_path: str, local_path: Path) -> Dict[str, Any]: + response = self.client.get(f"{self.base_url}/fs/download", params={"path": remote_path}) + response.raise_for_status() + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(response.content) + return {"ok": True, "path": str(local_path), "bytes": len(response.content)} + + def fs_read(self, remote_path: str) -> str: + response = self.client.get(f"{self.base_url}/fs/download", params={"path": remote_path}) + response.raise_for_status() + return response.content.decode("utf-8", errors="replace") + + def fs_delete(self, path: str) -> Any: + return self.post("/fs/delete", params={"path": path}) + + def fs_rename(self, path: str, new_name: str) -> Any: + return self.post("/fs/rename", params={"path": path, "newName": new_name}) + + def screen_raw(self, pixels: bytes, width: int, height: int) -> Any: + expected = width * height * 2 + if len(pixels) != expected: + raise ValueError(f"RGB565 frame is {len(pixels)} bytes; expected {expected} for {width}x{height}") + response = self.client.post( + f"{self.base_url}/api/screen/raw", + params={"w": width, "h": height}, + content=pixels, + headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(pixels))}, + ) + return self._response(response) + + def screen_clear_raw(self, width: int, height: int) -> Any: + return self.screen_raw(bytes(width * height * 2), width, height) + + @staticmethod + def _rgb565(image: Image.Image) -> bytes: + pixels = bytearray() + for red, green, blue in image.convert("RGB").get_flattened_data(): + pixels.extend((((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3)).to_bytes(2, "big")) + return bytes(pixels) + + def screen_text(self, message: str, width: int, height: int, clear_first: bool = True) -> Dict[str, Any]: + clear_result = self.screen_clear_raw(width, height) if clear_first else None + image = Image.new("RGB", (width, height), (14, 18, 38)) + draw = ImageDraw.Draw(image) + font_path = next((path for path in ( + "/System/Library/Fonts/SFNS.ttf", + "/Library/Fonts/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ) if Path(path).exists()), None) + font_size = max(16, round(width / 16)) + font = ImageFont.truetype(font_path, font_size) if font_path else ImageFont.load_default() + margin = max(1, min(8, min(width, height) // 10)) + draw.rectangle((margin, margin, width - margin - 1, height - margin - 1), outline=(90, 110, 180), width=2) + draw.multiline_text((width // 2, height // 2), message, font=font, fill=(245, 245, 232), anchor="mm", align="center", spacing=5) + write_result = self.screen_raw(self._rgb565(image), width, height) + return {"clear_first": clear_first, "clear": clear_result, "write": write_result, "width": width, "height": height} diff --git a/tests/test_notes_deferred.py b/tests/test_notes_deferred.py index 14e9f99..cc4ab18 100644 --- a/tests/test_notes_deferred.py +++ b/tests/test_notes_deferred.py @@ -12,7 +12,7 @@ from types import SimpleNamespace from typer.testing import CliRunner from reyna_cli.cli import app -from reyna_cli.notes_direct import create_note, list_notes, read_note +from reyna_cli.notes_direct import create_note, delete_note, list_notes, read_note, update_note runner = CliRunner() @@ -63,6 +63,25 @@ def test_read_and_create_notes_use_json_arguments_without_script_interpolation() assert created["id"] == "new-1" +def test_update_and_delete_notes_use_fixed_jxa_and_json_arguments(): + updated = update_note( + "x-coredata://note-1", + title="Updated", + body="New body", + runner=_success_runner( + {"id": "x-coredata://note-1", "title": "Updated", "body": "New body"}, + {"id": "x-coredata://note-1", "title": "Updated"}, + ), + ) + assert updated["title"] == "Updated" + + deleted = delete_note( + "x-coredata://note-1", + runner=_success_runner({"id": "x-coredata://note-1"}, {"id": "x-coredata://note-1", "deleted": True}), + ) + assert deleted["deleted"] is True + + def test_notes_cli_is_available_at_top_level_and_macmini_alias(monkeypatch): monkeypatch.setattr("reyna_cli.cli.list_notes", lambda **_: [{"id": "n1", "title": "Test"}]) diff --git a/tests/test_tactility.py b/tests/test_tactility.py new file mode 100644 index 0000000..92a621b --- /dev/null +++ b/tests/test_tactility.py @@ -0,0 +1,113 @@ +import json +from pathlib import Path + +import httpx +from typer.testing import CliRunner + +from reyna_cli.cli import app +from reyna_cli.config import Device, Registry, get_device, resolve_device_host +from reyna_cli.tactility import TactilityClient + +runner = CliRunner() + + +def test_friendly_name_lookup_is_case_insensitive(): + registry = Registry(devices=[Device(id="kidsos_5c5c", display_name="Grace's 2.8-inch Tactility Board")]) + assert get_device("Grace", registry).id == "kidsos_5c5c" + assert get_device("grace", registry).id == "kidsos_5c5c" + + +def test_resolve_device_host_prefers_live_mdns(monkeypatch): + monkeypatch.setattr("reyna_cli.config.socket.gethostbyname", lambda host: "192.168.68.141") + device = Device(id="kidsos_1234", host="kidsos-1234.local", reserved_ip="192.168.68.99") + assert resolve_device_host(device) == "192.168.68.141" + + +def test_resolve_device_host_falls_back_when_mdns_is_offline(monkeypatch): + def fail(_host): + raise OSError("offline") + + monkeypatch.setattr("reyna_cli.config.socket.gethostbyname", fail) + device = Device(id="kidsos_1234", host="kidsos-1234.local", reserved_ip="192.168.68.99") + assert resolve_device_host(device) == "192.168.68.99" + + +def test_tactility_client_uses_web_api(monkeypatch, tmp_path): + requests = [] + + def handler(request: httpx.Request): + requests.append(request) + if request.url.path == "/api/sysinfo": + return httpx.Response(200, json={"version": "0.8.0-dev"}) + if request.url.path == "/api/apps": + return httpx.Response(200, json={"apps": [{"id": "one.tactility.demo"}]}) + if request.url.path == "/fs/list": + return httpx.Response(200, json={"path": "/sdcard", "entries": []}) + return httpx.Response(200, json={"ok": True}) + + client = TactilityClient("http://kidsos-1234.local", transport=httpx.MockTransport(handler)) + assert client.sysinfo()["version"] == "0.8.0-dev" + assert client.apps()["apps"][0]["id"] == "one.tactility.demo" + assert client.fs_list("/sdcard")["path"] == "/sdcard" + assert requests[0].url.host == "kidsos-1234.local" + + +def test_tactility_install_and_run_requests(tmp_path): + requests = [] + + def handler(request: httpx.Request): + requests.append(request) + return httpx.Response(200, json={"ok": True}) + + app_file = tmp_path / "demo.app" + app_file.write_bytes(b"APP") + client = TactilityClient("http://192.168.68.99", transport=httpx.MockTransport(handler)) + client.install_app(app_file) + client.run_app("one.tactility.demo") + + assert requests[0].method == "PUT" + assert requests[0].url.path == "/api/apps/install" + assert requests[1].url.path == "/api/apps/run" + assert requests[1].url.params["id"] == "one.tactility.demo" + + +def test_tactility_screen_text_clears_before_write(): + requests = [] + + def handler(request: httpx.Request): + requests.append(request) + return httpx.Response(200, json={"ok": True}) + + client = TactilityClient("http://192.168.68.99", transport=httpx.MockTransport(handler)) + result = client.screen_text("Grace", 20, 10, clear_first=True) + + assert result["clear_first"] is True + assert [request.url.path for request in requests] == ["/api/screen/raw", "/api/screen/raw"] + assert requests[0].content == bytes(20 * 10 * 2) + assert len(requests[1].content) == 20 * 10 * 2 + + +def test_tactility_cli_has_commands(monkeypatch, tmp_path): + registry_path = tmp_path / "devices.yaml" + registry_path.write_text( + """ +devices: + kidsos_1234: + type: esp32-s3-tactility + host: kidsos-1234.local + reserved_ip: 192.168.68.99 +""", + encoding="utf-8", + ) + monkeypatch.setenv("REYNA_DEVICES_REGISTRY", str(registry_path)) + + result = runner.invoke(app, ["tactility", "--help"]) + assert result.exit_code == 0 + for command in ("discover", "sysinfo", "apps", "install", "run", "report", "fs", "screen"): + assert command in result.stdout + + +def test_robot_arm_is_not_a_cli_surface(): + result = runner.invoke(app, ["devices", "--help"]) + assert result.exit_code == 0 + assert "arm" not in result.stdout.lower()