feat: add tactility controls and notes CRUD

This commit is contained in:
Adolfo Reyna
2026-08-17 13:19:36 -04:00
parent c445093186
commit 1996a123d9
7 changed files with 426 additions and 18 deletions
+30 -1
View File
@@ -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] = {}
+27 -3
View File
@@ -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"
+74
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 = "<h1>" + escapeHtml(title) + "</h1><div>" + escapeHtml(body).replace(/\\n/g, "<br>") + "</div>";
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
+123
View File
@@ -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}