diff --git a/AGENTS.md b/AGENTS.md index c62a3ce..906df5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,14 +91,19 @@ ## Raspberry Pi Endpoint Provider Rules - Raspberry Pi 5 / Linux ARM64 support is an Endpoint Provider only. Do not turn it into Core and do not let it own Thread, Message, Run, Scheduler, `system.heartbeat`, Memory, Delivery, Operation records, ToolRouter, or persistence. -- Raspberry Pi provider work lives under `endpoint_providers/raspberry_pi/`, with docs in `docs/endpoints/raspberry-pi.md`, `docs/endpoints/rpi-capabilities.md`, and `docs/endpoints/rpi-security.md`. +- Raspberry Pi provider work lives under `endpoint_providers/raspberry_pi/`, with docs in `docs/endpoints/raspberry-pi.md`, `docs/endpoints/rpi-capabilities.md`, `docs/endpoints/rpi-security.md`, `docs/endpoints/rpi-operations.md`, and real hardware acceptance records under `docs/endpoints/rpi-real-acceptance-*.md`. - The Pi must actively connect to Core through `GET /endpoint/ws` using `meetyou.endpoint.ws.v4`; do not add an inbound Pi server or revive `/client/ws`. - Pi executable tools must be advertised as EndpointCapability snapshots and executed only from Core-routed `tool.call.request` frames. Results must return through `tool.call.accepted` / `tool.call.progress` / `tool.call.result` / `tool.call.error`. - `endpoint.heartbeat` from the Pi is connection keepalive only and must not trigger Scheduler-owned `system.heartbeat`. - GPIO and shell capabilities must be deny-by-default: no GPIO outside `security.gpio_allowed_pins`, no arbitrary shell strings, no `shell=True`, and `rpi.shell.safe_exec` must not be advertised unless enabled with an allowlist. +- Raspberry Pi device abstractions (`rpi.device.*` / `rpi.button.read`) must remain endpoint-local wrappers over allowlisted GPIO. Device config must validate `device_id`, BCM pin allowlist, type/direction, relay confirmation defaults, and pulse/blink bounds without changing Core routing or endpoint protocol ownership. +- Raspberry Pi device capabilities must surface through existing endpoint inventory and tool-call paths: `list_endpoint_tool_targets`, `list_active_endpoints`, and `send_endpoint_message(delivery_kind="tool_call")`. Do not add Pi-specific Core inventory APIs or endpoint protocol extensions for device calls. +- Raspberry Pi device confirmation is currently capability-level, not per-device. A write-class `rpi.device.*` capability may advertise `requires_confirmation=true` when any configured output device requires confirmation; do not redesign Core approval for per-device confirmation unless explicitly scoped. - Raspberry Pi GPIO arguments use BCM numbering, not physical header pin numbers. - Raspberry Pi 5 GPIO must use gpiozero with `lgpio`; keep `MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio` in the service environment and do not fall back to legacy `RPi.GPIO`/native backends. +- Raspberry Pi output device status reads must not open floating gpiozero inputs without `active_state`; Desk LED / Relay status on pins such as BCM 17/27 should use the lgpio backend and explicit active-state handling rather than surfacing gpiozero `active_state` errors to Core. - The Pi systemd service must run as `meetyou-rpi`, include the `gpio` supplementary group, and use `/var/lib/meetyou-rpi` for `WorkingDirectory` / `TMPDIR`; `lgpio` creates `.lgd-*` runtime files and must not write into `/opt/meetyou/MeetYou`. +- Manual Pi diagnostics run as `meetyou-rpi` from `/var/lib/meetyou-rpi` must include `PYTHONPATH=/opt/meetyou/MeetYou`; the endpoint package imports the repository-local `endpoint_tool_sdk`, and systemd sets this environment variable for the service. - GPIO diagnostics for the service user should run from `/var/lib/meetyou-rpi`, not from the repository checkout. `can not open gpiochip` means `/dev/gpiochip*` permission/group setup is wrong, not a ToolRouter problem. - The Pi endpoint may emit operation progress/result/error, but final assistant replies must still be persisted by Core MessageService. @@ -219,6 +224,8 @@ - Desktop Provider: `python main.py desktop-client` or `python -m desktop_client` - Edge Provider: `python main.py edge-client` or `python -m edge_client` - Research Adapter: `python -m research_adapter` +- Raspberry Pi endpoint health on Pi: `sudo -u meetyou-rpi env PYTHONPATH=/opt/meetyou/MeetYou TMPDIR=/var/lib/meetyou-rpi bash -lc 'cd /var/lib/meetyou-rpi && /opt/meetyou/MeetYou/.venv-rpi/bin/python -m meetyou_rpi_endpoint.health --config /etc/meetyou/rpi-endpoint.json --env-file /etc/meetyou/rpi-endpoint.env'` +- Raspberry Pi endpoint smoke: `bash scripts/rpi/smoke-test.sh /etc/meetyou/rpi-endpoint.json` - Frontend development: run `npm install`, `npm run dev` under `meetyou-ui/` - Frontend verification: run `npm run typecheck`, `npm run test` under `meetyou-ui/` - Frontend build: run `npm run build` under `meetyou-ui/` diff --git a/core/tool_runtime/registry.py b/core/tool_runtime/registry.py index 8e4d195..3493bd2 100644 --- a/core/tool_runtime/registry.py +++ b/core/tool_runtime/registry.py @@ -231,13 +231,14 @@ "name": "list_endpoint_tool_targets", "description": ( "List online Endpoint execution targets that can execute tools, optionally scoped to a workspace and tool key. " + "Examples include desktop file.read/shell.exec and Raspberry Pi rpi.device.list/status/set/pulse/blink/button.read. " "When answering the user, prefer tool_target_lines, endpoint_ids, and executable_tools_by_endpoint before verbose endpoint records." ), "parameters": { "type": "object", "properties": { "workspace_id": {"type": "string", "description": "Optional workspace id.", "default": ""}, - "tool_key": {"type": "string", "description": "Optional endpoint capability tool key, such as file.read or shell.exec.", "default": ""}, + "tool_key": {"type": "string", "description": "Optional endpoint capability tool key, such as file.read, shell.exec, or rpi.device.list.", "default": ""}, "include_tools": {"type": "boolean", "description": "Include each endpoint's capability list.", "default": True}, "include_capability_details": {"type": "boolean", "description": "Include verbose per-capability metadata. Keep false for ordinary inventory answers.", "default": False}, "include_endpoint_details": {"type": "boolean", "description": "Include verbose endpoint records. Keep false so compact endpoint rows stay complete.", "default": False}, @@ -278,7 +279,10 @@ "normal current-session reply path. Do not use this to answer the originating user or to send " "progress updates to the same endpoint; use emit_progress_notice for progress and the final assistant " "answer for the actual reply. Use only when the user explicitly asks to notify or call a " - "specific target Endpoint." + "specific target Endpoint. For Raspberry Pi device calls, use delivery_kind=tool_call with " + "target_id set to the rpi endpoint_id, tool_key such as rpi.device.list or rpi.device.set, " + "arguments such as {\"device_id\":\"desk_led\",\"value\":true}, and confirmed=true only after " + "explicit confirmation when the capability requires it." ), "parameters": { "type": "object", diff --git a/deploy/systemd/meetyou-rpi-endpoint.service b/deploy/systemd/meetyou-rpi-endpoint.service index 6000f91..bd84c75 100644 --- a/deploy/systemd/meetyou-rpi-endpoint.service +++ b/deploy/systemd/meetyou-rpi-endpoint.service @@ -10,6 +10,7 @@ Group=meetyou-rpi SupplementaryGroups=gpio WorkingDirectory=/var/lib/meetyou-rpi EnvironmentFile=/etc/meetyou/rpi-endpoint.env +Environment=MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio Environment=PYTHONPATH=/opt/meetyou/MeetYou Environment=TMPDIR=/var/lib/meetyou-rpi ExecStart=/opt/meetyou/MeetYou/.venv-rpi/bin/python -m meetyou_rpi_endpoint.main --config /etc/meetyou/rpi-endpoint.json diff --git a/docs/endpoints/raspberry-pi.md b/docs/endpoints/raspberry-pi.md index 5bca594..a7a3c7c 100644 --- a/docs/endpoints/raspberry-pi.md +++ b/docs/endpoints/raspberry-pi.md @@ -11,7 +11,7 @@ Implementation decisions: - Advertise a single execution endpoint, `rpi..executor`; do not create Thread, Message, Run, Scheduler, Heartbeat, Delivery, or Operation state on the Pi. - Map requested operation terms to current MeetYou frames: Core sends `tool.call.request` with `operation_id` and `call_id`; the Pi returns `tool.call.accepted`, `tool.call.progress`, `tool.call.result`, or `tool.call.error`. - Keep `endpoint.heartbeat` as connection keepalive only. It must not trigger `system.heartbeat`, which remains Scheduler-owned by Core. -- Start with `rpi.echo`, `rpi.system.info`, `rpi.gpio.read`, `rpi.gpio.write`, and optional `rpi.shell.safe_exec`; camera, audio, display, and sensor streaming remain future work. +- Start with `rpi.echo`, `rpi.system.info`, low-level `rpi.gpio.read` / `rpi.gpio.write`, named device capabilities, and optional `rpi.shell.safe_exec`; camera, audio, display, and sensor streaming remain future work. - Keep GPIO and shell policies local, explicit, and deny-by-default. GPIO writes require an allowlisted pin. Safe shell is not advertised unless enabled and an allowlist is configured. ## Architecture Summary @@ -117,6 +117,7 @@ Key fields: - `keepalive`: endpoint connection heartbeat interval and timeout policy. - `operation`: default and max local operation timeout. - `security`: sandbox, safe-shell allowlist, GPIO allowlist, and default GPIO write duration. +- `devices`: named device abstraction records. Each record uses a stable `device_id`, BCM `pin`, `type`, `direction`, `active_high`, and optional output/input safety fields. Environment overrides: @@ -142,7 +143,7 @@ sudo systemctl status meetyou-rpi-endpoint journalctl -u meetyou-rpi-endpoint -f ``` -A healthy startup shows `hello acknowledged` followed by `ready: {'registered_capability_count': 4}`. That confirms Core accepted the endpoint and capability snapshot. +A healthy startup shows `hello acknowledged` followed by `ready: {'registered_capability_count': 10}` when safe shell is disabled and the default device capability set is advertised. That confirms Core accepted the endpoint and capability snapshot. Smoke test before starting systemd: @@ -150,6 +151,10 @@ Smoke test before starting systemd: bash scripts/rpi/smoke-test.sh ``` +The smoke wrapper first runs the production health check, which verifies config presence, token env presence without printing the token, sandbox writability, `lgpio`, `gpio` group membership, `/dev/gpiochip*` permissions, and systemd status with explicit `PASS` / `WARN` / `FAIL` lines. See `docs/endpoints/rpi-operations.md` for the operations runbook and `docs/endpoints/rpi-real-acceptance-2026-05-13.md` for the real Raspberry Pi 5 acceptance record. + +When running the health module manually as `meetyou-rpi` from `/var/lib/meetyou-rpi`, include `PYTHONPATH=/opt/meetyou/MeetYou`. The systemd unit already sets it, but manual commands need it so `meetyou_rpi_endpoint` can import the repository-local `endpoint_tool_sdk`. + Raspberry Pi 5 GPIO must use the `lgpio` pin factory. `/etc/meetyou/rpi-endpoint.env` should include: ```bash diff --git a/docs/endpoints/rpi-capabilities.md b/docs/endpoints/rpi-capabilities.md index ef9d0d1..0eedf04 100644 --- a/docs/endpoints/rpi-capabilities.md +++ b/docs/endpoints/rpi-capabilities.md @@ -17,7 +17,7 @@ Raspberry Pi capabilities are endpoint-local executable tools advertised through - Risk: `read` - Confirmation: no - Input: empty object -- Output: hostname, platform, Python version, uptime when available, memory summary when available, disk summary, and CPU temperature when available. +- Output: hostname, platform, Python version, uptime when available, memory summary when available, disk summary, CPU temperature when available, GPIO backend info, endpoint version, and git commit when available. - Notes: CPU temperature and `/proc` data are optional. The capability must not fail on non-Pi development machines just because those files are absent. ### `rpi.gpio.read` @@ -42,6 +42,110 @@ Raspberry Pi capabilities are endpoint-local executable tools advertised through - Raspberry Pi 5: requires `lgpio`; install `python3-lgpio` or pip package `lgpio` and keep the service env set to `MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio`. - Device access: `can not open gpiochip` means the service user lacks `/dev/gpiochip*` permission. The deployment path adds `meetyou-rpi` to the `gpio` group and the systemd unit uses `SupplementaryGroups=gpio`. +### Device abstraction capabilities + +These wrap allowlisted GPIO pins with named, typed device records from `devices` in the Raspberry Pi endpoint config. They do not replace `rpi.gpio.read` / `rpi.gpio.write`; the low-level primitives remain available for diagnostics and controlled one-off operations. + +Device config fields: + +- `device_id`: stable identifier used in operation arguments, for example `desk_led`. +- `type`: `led`, `relay`, `output`, `button`, or `input`. +- `name`: human-readable display name. +- `pin`: BCM GPIO number; it must also appear in `security.gpio_allowed_pins`. +- `direction`: `out` for `led` / `relay` / `output`; `in` for `button` / `input`. +- `active_high`: logical `true` maps to physical high when true, and to physical low when false. +- `max_on_ms`: optional output safety limit used by pulse and blink. +- `requires_confirmation`: optional output confirmation override. Relays default to true when omitted. +- `pull`: optional input pull mode, one of `up`, `down`, or `none`. + +Example: + +```json +{ + "security": { + "gpio_allowed_pins": [17, 27, 22] + }, + "devices": [ + { + "device_id": "desk_led", + "type": "led", + "name": "Desk LED", + "pin": 17, + "direction": "out", + "active_high": true, + "max_on_ms": 5000, + "requires_confirmation": false + }, + { + "device_id": "relay_1", + "type": "relay", + "name": "Relay 1", + "pin": 27, + "direction": "out", + "active_high": true, + "max_on_ms": 3000 + }, + { + "device_id": "button_1", + "type": "button", + "name": "Button 1", + "pin": 22, + "direction": "in", + "active_high": false, + "pull": "up" + } + ] +} +``` + +### `rpi.device.list` + +- Risk: `read` +- Confirmation: no +- Input: empty object +- Output: configured device records with `device_id`, type, name, BCM pin, direction, `active_high`, and non-secret safety metadata. +- Policy: never returns tokens or endpoint secrets. + +### `rpi.device.status` + +- Risk: `read` +- Confirmation: no +- Input: `{ "device_id": string }` +- Output: device metadata, logical `value`, and raw GPIO `raw_value`. +- Policy: rejects unknown devices and rejects any config drift that places the device pin outside `security.gpio_allowed_pins`. + +### `rpi.device.set` + +- Risk: `local_write` +- Confirmation: yes when any configured output device requires confirmation; otherwise no. +- Input: `{ "device_id": string, "value": boolean|0|1|"on"|"off" }` +- Output: device metadata, logical value, raw GPIO value, and backend name. +- Policy: only `direction=out` devices can be set. Input devices fail with `device_permission_denied`. + +### `rpi.device.pulse` + +- Risk: `local_write` +- Confirmation: yes when any configured output device requires confirmation; otherwise no. +- Input: `{ "device_id": string, "duration_ms": integer }` +- Output: device metadata, duration, backend, and `reset_performed=true`. +- Policy: only output devices are allowed. `duration_ms` must be positive and must not exceed the device `max_on_ms`, or `5000` ms when no per-device limit is configured. + +### `rpi.device.blink` + +- Risk: `local_write` +- Confirmation: yes when any configured output device requires confirmation; otherwise no. +- Input: `{ "device_id": string, "count": integer, "interval_ms": integer }` +- Output: device metadata, count, interval, maximum total duration, backend, and final logical value false. +- Policy: only output devices are allowed. Count is capped at 20, interval is capped at 10000 ms and by output `max_on_ms`, and total duration is capped at 60000 ms. + +### `rpi.button.read` + +- Risk: `read` +- Confirmation: no +- Input: `{ "device_id": string }` +- Output: same shape as `rpi.device.status`. +- Policy: only configured `type=button`, `direction=in` devices are accepted. + ## Disabled By Default ### `rpi.shell.safe_exec` diff --git a/docs/endpoints/rpi-operations.md b/docs/endpoints/rpi-operations.md new file mode 100644 index 0000000..e15f4b9 --- /dev/null +++ b/docs/endpoints/rpi-operations.md @@ -0,0 +1,177 @@ +# Raspberry Pi Endpoint Operations + +This runbook is for operating the Raspberry Pi 5 Endpoint Provider after the MVP hardware path has been validated. The Pi remains an Endpoint Provider only: Core owns Thread, Message, Run, Scheduler, Delivery, ToolRouter, Operation records, and persistence. + +## Health And Smoke Test + +Run the production health check as the service user on the Pi: + +```bash +sudo -u meetyou-rpi env PYTHONPATH=/opt/meetyou/MeetYou TMPDIR=/var/lib/meetyou-rpi \ + bash -lc 'cd /var/lib/meetyou-rpi && /opt/meetyou/MeetYou/.venv-rpi/bin/python -m meetyou_rpi_endpoint.health --config /etc/meetyou/rpi-endpoint.json --env-file /etc/meetyou/rpi-endpoint.env' +``` + +Keep `PYTHONPATH=/opt/meetyou/MeetYou` in manual commands that run from `/var/lib/meetyou-rpi`. The installed Pi package imports the repository-local `endpoint_tool_sdk`; the systemd unit already sets `PYTHONPATH`, but ad hoc `sudo -u meetyou-rpi ... python -m meetyou_rpi_endpoint.health` commands must set it explicitly. + +Or use the wrapper: + +```bash +sudo REPO_DIR=/opt/meetyou/MeetYou PYTHON_BIN=/opt/meetyou/MeetYou/.venv-rpi/bin/python PYTHONPATH=/opt/meetyou/MeetYou \ + bash /opt/meetyou/MeetYou/scripts/rpi/smoke-test.sh /etc/meetyou/rpi-endpoint.json +``` + +The health check prints one line per check with `PASS`, `WARN`, or `FAIL`. It verifies: + +- Config file exists and loads. +- Token environment variable exists; the value is never printed. +- `security.sandbox_dir` is writable. +- GPIO backend is available and Raspberry Pi 5 uses `gpiozero` with `lgpio`. +- Current user is in the `gpio` group. +- `/dev/gpiochip*` devices are readable and writable by the process user. +- `meetyou-rpi-endpoint.service` state is visible through systemd. + +`FAIL` means the endpoint is not production-ready. `WARN` means the check is not applicable or not fully validated in the current shell, for example running on a non-Pi development machine. + +## Expected Production Shape + +- Service user: `meetyou-rpi`. +- Supplementary group: `gpio`. +- Working directory: `/var/lib/meetyou-rpi`. +- Sandbox directory: `/var/lib/meetyou-rpi/sandbox`. +- GPIO factory: `MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio`. +- Token env: `MEETYOU_RPI_ENDPOINT_TOKEN` in `/etc/meetyou/rpi-endpoint.env`. +- Config: `/etc/meetyou/rpi-endpoint.json`. +- No inbound Pi server. The endpoint connects outward to Core with `GET /endpoint/ws`. + +Check the unit: + +```bash +systemctl cat meetyou-rpi-endpoint | grep -E 'User=|Group=|SupplementaryGroups|WorkingDirectory|TMPDIR|EnvironmentFile' +systemctl status meetyou-rpi-endpoint +journalctl -u meetyou-rpi-endpoint -n 100 --no-pager +``` + +Do not print or paste tokens from `/etc/meetyou/rpi-endpoint.env` into logs, tickets, or commits. + +## GPIO Diagnostics + +Raspberry Pi GPIO arguments use BCM numbering, not physical header pin numbers. Physical header pin 21 is BCM 9; BCM 21 is physical header pin 40. + +If a GPIO operation fails with `can not open gpiochip`, treat it as a Linux device permission or group problem, not as a ToolRouter problem. Verify: + +```bash +id meetyou-rpi +ls -l /dev/gpiochip* +systemctl cat meetyou-rpi-endpoint | grep -E 'SupplementaryGroups|User=|Group=' +``` + +If imports fail, rebuild the Pi venv after installing OS GPIO packages: + +```bash +sudo apt install -y python3-gpiozero python3-lgpio +sudo systemctl stop meetyou-rpi-endpoint +sudo rm -rf /opt/meetyou/MeetYou/.venv-rpi +sudo REPO_DIR=/opt/meetyou/MeetYou bash /opt/meetyou/MeetYou/scripts/rpi/install-systemd.sh +sudo systemctl restart meetyou-rpi-endpoint +``` + +Run GPIO diagnostics from `/var/lib/meetyou-rpi`, not from `/opt/meetyou/MeetYou`, because `lgpio` creates short-lived `.lgd-*` files in the process working directory. + +If device status for an output device such as `desk_led` on BCM 17 or `relay_1` on BCM 27 fails with a gpiozero `active_state` error, verify the service has the lgpio pin factory and restart it: + +```bash +grep -E '^MEETYOU_RPI_GPIO_PIN_FACTORY=' /etc/meetyou/rpi-endpoint.env +systemctl cat meetyou-rpi-endpoint | grep MEETYOU_RPI_GPIO_PIN_FACTORY +sudo systemctl daemon-reload +sudo systemctl restart meetyou-rpi-endpoint +``` + +The expected value is `MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio`. The installer appends this value to existing env files when missing, and the service template pins it in the systemd environment. + +## Device Configuration And Diagnostics + +Named devices live in the Raspberry Pi endpoint config under `devices`. Every configured device pin must also be listed in `security.gpio_allowed_pins`; startup rejects duplicate `device_id` values, pins outside the allowlist, invalid direction/type pairs, and invalid input pull modes. + +Minimal examples: + +```json +{ + "security": { + "gpio_allowed_pins": [17, 27, 22] + }, + "devices": [ + { + "device_id": "desk_led", + "type": "led", + "name": "Desk LED", + "pin": 17, + "direction": "out", + "active_high": true, + "max_on_ms": 5000, + "requires_confirmation": false + }, + { + "device_id": "relay_1", + "type": "relay", + "name": "Relay 1", + "pin": 27, + "direction": "out", + "active_high": true, + "max_on_ms": 3000 + }, + { + "device_id": "button_1", + "type": "button", + "name": "Button 1", + "pin": 22, + "direction": "in", + "active_high": false, + "pull": "up" + } + ] +} +``` + +Operational checks after editing `/etc/meetyou/rpi-endpoint.json`: + +```bash +sudo -u meetyou-rpi env PYTHONPATH=/opt/meetyou/MeetYou TMPDIR=/var/lib/meetyou-rpi \ + bash -lc 'cd /var/lib/meetyou-rpi && /opt/meetyou/MeetYou/.venv-rpi/bin/python -m meetyou_rpi_endpoint.health --config /etc/meetyou/rpi-endpoint.json --env-file /etc/meetyou/rpi-endpoint.env' +sudo systemctl restart meetyou-rpi-endpoint +journalctl -u meetyou-rpi-endpoint -n 100 --no-pager +``` + +Device capability failures are intentionally specific: + +- `device_not_found`: the requested `device_id` is not configured. +- `device_pin_not_allowed` or `gpio_pin_not_allowed`: config or runtime pin is outside `security.gpio_allowed_pins`. +- `device_permission_denied`: a write was attempted on an input device or a button read targeted a non-button device. +- `invalid_device_*`: malformed value, duration, blink count, interval, or config. +- `gpio_unavailable` / `gpio_backend_*`: `gpiozero` / `lgpio` is missing or cannot access `/dev/gpiochip*`. + +Relay devices default to requiring Core confirmation. Because the endpoint protocol does not include a per-operation confirmation receipt, the Pi advertises write-class device capabilities with `requires_confirmation=true` whenever any configured output device requires confirmation. If a deployment has only LEDs or explicitly sets a relay `requires_confirmation=false`, the advertised flag can be false. + +## Runtime Acceptance Signals + +A healthy Pi endpoint shows: + +- Core acknowledges `endpoint.hello`. +- Core sends `endpoint.ready` with `registered_capability_count = 10` when safe shell is disabled and the default device capability set is advertised. +- `rpi.echo` returns the requested text. +- `rpi.system.info` returns hostname, platform, Python version, uptime, memory, disk, optional CPU temperature, GPIO backend info, endpoint version, and git commit when available. +- `rpi.gpio.write` succeeds only for pins in `security.gpio_allowed_pins`. +- `rpi.device.list` returns configured devices without tokens or secrets. +- `rpi.device.set`, `rpi.device.pulse`, and `rpi.device.blink` operate only on `direction=out` devices and enforce duration/count limits. +- `rpi.button.read` operates only on configured button inputs. + +`rpi.shell.safe_exec` must remain absent unless both `security.safe_shell_enabled=true` and a reviewed allowlist are configured. + +## Restart And Rollback + +Restart the endpoint only: + +```bash +sudo systemctl restart meetyou-rpi-endpoint +``` + +This does not touch Core database state because the Pi provider owns no durable Thread, Message, Run, Scheduler, Delivery, ToolRouter, Operation, or persistence records. If Core itself is rolled back, Core Service still owns database migration and protocol negotiation; keep a matching PostgreSQL snapshot for safe Core rollback. diff --git a/docs/endpoints/rpi-real-acceptance-2026-05-13.md b/docs/endpoints/rpi-real-acceptance-2026-05-13.md new file mode 100644 index 0000000..6c22694 --- /dev/null +++ b/docs/endpoints/rpi-real-acceptance-2026-05-13.md @@ -0,0 +1,54 @@ +# Raspberry Pi 5 Real Acceptance - 2026-05-13 + +This record captures the real hardware acceptance path completed on 2026-05-13. It is not a simulation report. + +## Scope + +- Device: Raspberry Pi 5. +- Role: MeetYou Endpoint Provider only. +- Protocol: `GET /endpoint/ws` using `meetyou.endpoint.ws.v4`. +- Capabilities validated: `rpi.echo`, `rpi.gpio.write`, and endpoint capability registration. +- Not included: camera, audio, display, sensor streaming, provider-owned final replies, Core redesign, or endpoint protocol redesign. + +## Acceptance Evidence + +- Core saw the endpoint online after `endpoint.hello`; hello was acknowledged. +- Core sent ready after capability snapshot; `registered_capability_count = 4`. +- `rpi.echo` executed successfully through Core-routed tool execution. +- `rpi.gpio.write` executed successfully on real Raspberry Pi GPIO. +- GPIO backend was `lgpio`. +- systemd unit used `SupplementaryGroups=gpio`. +- Runtime state and working directory used `/var/lib/meetyou-rpi`. +- GPIO pin arguments used BCM numbering. +- A `can not open gpiochip` failure was diagnosed as a `/dev/gpiochip*` permission or group problem, not a ToolRouter or Core routing problem. + +## Production Invariants Confirmed + +- Core owns Thread, Message, Run, Scheduler, Delivery, ToolRouter, Operation records, and persistence. +- Raspberry Pi endpoint actively connects to Core and does not expose an inbound Pi server. +- The Pi advertises EndpointCapability snapshots and executes only Core-routed `tool.call.request` frames. +- Results return through `tool.call.accepted`, `tool.call.progress`, `tool.call.result`, or `tool.call.error`. +- `endpoint.heartbeat` remains connection keepalive only and does not trigger Scheduler-owned `system.heartbeat`. +- `/client/ws` was not reintroduced. +- `source_client_id` and `target_client_id` were not reintroduced as primary routing concepts. +- Arbitrary shell execution stayed disabled. +- GPIO writes stayed constrained by `security.gpio_allowed_pins`. +- Tokens stayed in environment/configuration paths and were not committed. + +## Follow-Up Operational Baseline + +Before treating a new Pi image, new circuit, or new Core deployment as production-ready, rerun: + +```bash +bash scripts/rpi/smoke-test.sh /etc/meetyou/rpi-endpoint.json +``` + +Then verify from Core: + +- Endpoint online. +- `registered_capability_count = 4` when safe shell is disabled. +- `rpi.echo` succeeds. +- `rpi.system.info` reports `gpio.backend` and endpoint metadata. +- `rpi.gpio.write` succeeds on an allowlisted BCM pin and rejects a non-allowlisted pin. + +Keep this acceptance record separate from local fake-GPIO tests. Fake GPIO proves local dispatch behavior only; it does not prove Raspberry Pi 5 device permissions or `lgpio` runtime behavior. diff --git a/endpoint_providers/raspberry_pi/README.md b/endpoint_providers/raspberry_pi/README.md index fe8341f..61e93b1 100644 --- a/endpoint_providers/raspberry_pi/README.md +++ b/endpoint_providers/raspberry_pi/README.md @@ -26,16 +26,77 @@ python -m pip install -e 'endpoint_providers/raspberry_pi[gpio]' meetyou-rpi-endpoint --config user/rpi_endpoint.json ``` +## Production health check + +```bash +sudo -u meetyou-rpi env PYTHONPATH=/opt/meetyou/MeetYou TMPDIR=/var/lib/meetyou-rpi \ + bash -lc 'cd /var/lib/meetyou-rpi && /opt/meetyou/MeetYou/.venv-rpi/bin/python -m meetyou_rpi_endpoint.health --config /etc/meetyou/rpi-endpoint.json --env-file /etc/meetyou/rpi-endpoint.env' +``` + +The health check reports `PASS` / `WARN` / `FAIL` for config, token env presence, sandbox writability, `lgpio`, `gpio` group membership, `/dev/gpiochip*` permissions, and systemd status. Token values are never printed. + +Manual service-user checks need `PYTHONPATH=/opt/meetyou/MeetYou` because the endpoint package imports the repository-local `endpoint_tool_sdk`. The systemd unit sets this automatically for the running service. + ## Capabilities - `rpi.echo` - `rpi.system.info` - `rpi.gpio.read` - `rpi.gpio.write` +- `rpi.device.list` +- `rpi.device.status` +- `rpi.device.set` +- `rpi.device.pulse` +- `rpi.device.blink` +- `rpi.button.read` - `rpi.shell.safe_exec`, disabled unless `security.safe_shell_enabled=true` and `security.safe_shell_allowlist` is non-empty GPIO uses `gpiozero` with `lgpio` on Raspberry Pi OS. Raspberry Pi 5 must not use the legacy `RPi.GPIO`/native backend; if you see `Cannot determine SOC peripheral base address`, install `lgpio` and set `MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio` in `/etc/meetyou/rpi-endpoint.env`. Local tests and simulation can use the fake GPIO backend. +The device capabilities wrap allowlisted BCM GPIO pins with named config records. They are safer for ordinary assistant use than raw GPIO because they validate `device_id`, type/direction, pulse duration, blink count, blink interval, and relay confirmation policy while preserving the low-level `rpi.gpio.read` / `rpi.gpio.write` primitives for diagnostics. + +Example device config: + +```json +{ + "security": { + "gpio_allowed_pins": [17, 27, 22] + }, + "devices": [ + { + "device_id": "desk_led", + "type": "led", + "name": "Desk LED", + "pin": 17, + "direction": "out", + "active_high": true, + "max_on_ms": 5000, + "requires_confirmation": false + }, + { + "device_id": "relay_1", + "type": "relay", + "name": "Relay 1", + "pin": 27, + "direction": "out", + "active_high": true, + "max_on_ms": 3000 + }, + { + "device_id": "button_1", + "type": "button", + "name": "Button 1", + "pin": 22, + "direction": "in", + "active_high": false, + "pull": "up" + } + ] +} +``` + +All `pin` values are BCM numbers and must also be present in `security.gpio_allowed_pins`. Relays default to `requires_confirmation=true` when the field is omitted. + ## Raspberry Pi 5 deployment notes - Token env key on the Pi: `MEETYOU_RPI_ENDPOINT_TOKEN`. @@ -46,7 +107,7 @@ GPIO uses `gpiozero` with `lgpio` on Raspberry Pi OS. Raspberry Pi 5 must not us ## Safety -The safe shell capability never accepts an arbitrary shell string and never uses `shell=True`. It runs only exact allowlisted argv templates inside the configured sandbox directory. GPIO read/write rejects pins outside `security.gpio_allowed_pins`. +The safe shell capability never accepts an arbitrary shell string and never uses `shell=True`. It runs only exact allowlisted argv templates inside the configured sandbox directory. GPIO read/write rejects pins outside `security.gpio_allowed_pins`. Device operations reject unknown `device_id` values, input-device writes, forbidden pins, unbounded pulses, and excessive blink requests. See `docs/endpoints/raspberry-pi.md`, `docs/endpoints/rpi-capabilities.md`, and `docs/endpoints/rpi-security.md` for deployment and security details. diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/base.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/base.py index 0a77a69..50b8ba2 100644 --- a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/base.py +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/base.py @@ -15,7 +15,7 @@ def __init__(self, code: str, message: str, *, retryable: bool = False): class GPIOBackend(Protocol): - async def read(self, pin: int) -> bool: + async def read(self, pin: int, *, pull: str | None = None) -> bool: raise NotImplementedError async def write(self, pin: int, value: bool, *, duration_ms: int | None = None) -> dict[str, Any]: @@ -26,6 +26,7 @@ async def write(self, pin: int, value: bool, *, duration_ms: int | None = None) class CapabilityContext: config: Any gpio_backend: GPIOBackend | None = None + device_registry: Any | None = None CapabilityHandler = Callable[[dict[str, Any], CapabilityContext], Awaitable[dict[str, Any]]] @@ -58,4 +59,3 @@ def to_tool_definition(self, *, endpoint_id: str, workspace_ids: list[str], max_ "input_schema": dict(self.input_schema or {}), "output_schema": dict(self.output_schema or {}), } - diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/device.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/device.py new file mode 100644 index 0000000..a5fa528 --- /dev/null +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/device.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from .base import ( + CapabilityContext, + CapabilityDefinition, + CapabilityError, +) +from .gpio import UnavailableGPIOBackend +from ..devices import DeviceRegistry, device_public_dict +from ..security import validate_gpio_pin + + +DEFAULT_DEVICE_MAX_ON_MS = 5_000 +MAX_BLINK_COUNT = 20 +MAX_BLINK_INTERVAL_MS = 10_000 +MAX_BLINK_TOTAL_MS = 60_000 + + +async def handle_device_list(arguments: dict[str, Any], context: CapabilityContext) -> dict[str, Any]: + del arguments + registry = _device_registry(context) + return {"devices": [device_public_dict(device) for device in registry.list()]} + + +async def handle_device_status(arguments: dict[str, Any], context: CapabilityContext) -> dict[str, Any]: + device = _require_device(arguments.get("device_id"), context) + backend = _gpio_backend(context) + _validate_device_pin(device, context) + raw_value = await backend.read(device.pin, pull=device.pull) + payload = device_public_dict(device) + payload.update( + { + "value": _logical_value(device, raw_value), + "raw_value": bool(raw_value), + } + ) + return payload + + +async def handle_device_set(arguments: dict[str, Any], context: CapabilityContext) -> dict[str, Any]: + device = _require_output_device(arguments.get("device_id"), context) + value = _parse_bool(arguments.get("value"), code="invalid_device_value", label="device value") + result = await _write_logical(device, value, context) + payload = device_public_dict(device) + payload.update( + { + "value": value, + "raw_value": _physical_value(device, value), + "backend": str(result.get("backend") or "unknown"), + } + ) + return payload + + +async def handle_device_pulse(arguments: dict[str, Any], context: CapabilityContext) -> dict[str, Any]: + device = _require_output_device(arguments.get("device_id"), context) + duration_ms = _duration_ms( + arguments.get("duration_ms"), + code="invalid_device_duration", + label="duration_ms", + maximum=device.max_on_ms or DEFAULT_DEVICE_MAX_ON_MS, + ) + on_result = await _write_logical(device, True, context) + try: + await asyncio.sleep(duration_ms / 1000.0) + finally: + off_result = await _write_logical(device, False, context) + payload = device_public_dict(device) + payload.update( + { + "value": False, + "duration_ms": duration_ms, + "reset_performed": True, + "backend": str(off_result.get("backend") or on_result.get("backend") or "unknown"), + } + ) + return payload + + +async def handle_device_blink(arguments: dict[str, Any], context: CapabilityContext) -> dict[str, Any]: + device = _require_output_device(arguments.get("device_id"), context) + count = _bounded_int( + arguments.get("count"), + code="invalid_device_blink", + label="count", + minimum=1, + maximum=MAX_BLINK_COUNT, + ) + interval_ms = _bounded_int( + arguments.get("interval_ms"), + code="invalid_device_blink", + label="interval_ms", + minimum=1, + maximum=MAX_BLINK_INTERVAL_MS, + ) + per_on_max = device.max_on_ms or DEFAULT_DEVICE_MAX_ON_MS + if interval_ms > per_on_max: + raise CapabilityError( + "invalid_device_blink", + f"interval_ms must be <= {per_on_max} for device {device.device_id}", + ) + conservative_total_ms = count * interval_ms * 2 + if conservative_total_ms > MAX_BLINK_TOTAL_MS: + raise CapabilityError( + "invalid_device_blink", + f"blink total duration must be <= {MAX_BLINK_TOTAL_MS} ms", + ) + + last_result: dict[str, Any] = {} + try: + for index in range(count): + last_result = await _write_logical(device, True, context) + await asyncio.sleep(interval_ms / 1000.0) + last_result = await _write_logical(device, False, context) + if index < count - 1: + await asyncio.sleep(interval_ms / 1000.0) + finally: + last_result = await _write_logical(device, False, context) + + payload = device_public_dict(device) + payload.update( + { + "value": False, + "count": count, + "interval_ms": interval_ms, + "max_total_duration_ms": conservative_total_ms, + "backend": str(last_result.get("backend") or "unknown"), + } + ) + return payload + + +async def handle_button_read(arguments: dict[str, Any], context: CapabilityContext) -> dict[str, Any]: + device = _require_device(arguments.get("device_id"), context) + if device.direction != "in" or device.type != "button": + raise CapabilityError( + "device_permission_denied", + f"device {device.device_id} is not a button input device", + ) + return await handle_device_status({"device_id": device.device_id}, context) + + +def build_device_list_capability() -> CapabilityDefinition: + return CapabilityDefinition( + name="rpi.device.list", + description="Raspberry Pi Device List", + input_schema={"type": "object", "properties": {}, "additionalProperties": False}, + output_schema={ + "type": "object", + "properties": {"devices": {"type": "array"}}, + "required": ["devices"], + }, + risk_level="read", + requires_confirmation=False, + handler=handle_device_list, + safe_parallel=True, + tags=("rpi", "device", "read"), + ) + + +def build_device_status_capability() -> CapabilityDefinition: + return CapabilityDefinition( + name="rpi.device.status", + description="Raspberry Pi Device Status", + input_schema={ + "type": "object", + "properties": {"device_id": {"type": "string"}}, + "required": ["device_id"], + "additionalProperties": False, + }, + output_schema={"type": "object"}, + risk_level="read", + requires_confirmation=False, + handler=handle_device_status, + safe_parallel=False, + tags=("rpi", "device", "read"), + ) + + +def build_device_set_capability(*, requires_confirmation: bool) -> CapabilityDefinition: + return CapabilityDefinition( + name="rpi.device.set", + description="Raspberry Pi Device Set", + input_schema={ + "type": "object", + "properties": { + "device_id": {"type": "string"}, + "value": {"type": ["boolean", "integer", "string"]}, + }, + "required": ["device_id", "value"], + "additionalProperties": False, + }, + output_schema={"type": "object"}, + risk_level="local_write", + requires_confirmation=requires_confirmation, + handler=handle_device_set, + safe_parallel=False, + tags=("rpi", "device", "write"), + ) + + +def build_device_pulse_capability(*, requires_confirmation: bool) -> CapabilityDefinition: + return CapabilityDefinition( + name="rpi.device.pulse", + description="Raspberry Pi Device Pulse", + input_schema={ + "type": "object", + "properties": { + "device_id": {"type": "string"}, + "duration_ms": {"type": "integer"}, + }, + "required": ["device_id", "duration_ms"], + "additionalProperties": False, + }, + output_schema={"type": "object"}, + risk_level="local_write", + requires_confirmation=requires_confirmation, + handler=handle_device_pulse, + safe_parallel=False, + tags=("rpi", "device", "write"), + ) + + +def build_device_blink_capability(*, requires_confirmation: bool) -> CapabilityDefinition: + return CapabilityDefinition( + name="rpi.device.blink", + description="Raspberry Pi Device Blink", + input_schema={ + "type": "object", + "properties": { + "device_id": {"type": "string"}, + "count": {"type": "integer"}, + "interval_ms": {"type": "integer"}, + }, + "required": ["device_id", "count", "interval_ms"], + "additionalProperties": False, + }, + output_schema={"type": "object"}, + risk_level="local_write", + requires_confirmation=requires_confirmation, + handler=handle_device_blink, + safe_parallel=False, + tags=("rpi", "device", "write"), + ) + + +def build_button_read_capability() -> CapabilityDefinition: + return CapabilityDefinition( + name="rpi.button.read", + description="Raspberry Pi Button Read", + input_schema={ + "type": "object", + "properties": {"device_id": {"type": "string"}}, + "required": ["device_id"], + "additionalProperties": False, + }, + output_schema={"type": "object"}, + risk_level="read", + requires_confirmation=False, + handler=handle_button_read, + safe_parallel=False, + tags=("rpi", "device", "button", "read"), + ) + + +def _device_registry(context: CapabilityContext) -> DeviceRegistry: + registry = context.device_registry + if isinstance(registry, DeviceRegistry): + return registry + return DeviceRegistry.from_config(context.config) + + +def _gpio_backend(context: CapabilityContext): + return context.gpio_backend or UnavailableGPIOBackend() + + +def _require_device(device_id: Any, context: CapabilityContext): + normalized = str(device_id or "").strip() + if not normalized: + raise CapabilityError("invalid_device_id", "device_id is required") + device = _device_registry(context).get(normalized) + if device is None: + raise CapabilityError("device_not_found", f"unknown Raspberry Pi device_id: {normalized}") + return device + + +def _require_output_device(device_id: Any, context: CapabilityContext): + device = _require_device(device_id, context) + if device.direction != "out": + raise CapabilityError( + "device_permission_denied", + f"device {device.device_id} has direction={device.direction}; write operation requires direction=out", + ) + return device + + +def _validate_device_pin(device, context: CapabilityContext) -> int: + return validate_gpio_pin(device.pin, context.config.security.gpio_allowed_pins) + + +async def _write_logical(device, value: bool, context: CapabilityContext) -> dict[str, Any]: + backend = _gpio_backend(context) + pin = _validate_device_pin(device, context) + return await backend.write(pin, _physical_value(device, value), duration_ms=0) + + +def _logical_value(device, raw_value: bool) -> bool: + raw = bool(raw_value) + return raw if device.active_high else not raw + + +def _physical_value(device, logical_value: bool) -> bool: + value = bool(logical_value) + return value if device.active_high else not value + + +def _parse_bool(value: Any, *, code: str, label: str) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, int) and not isinstance(value, bool) and value in {0, 1}: + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on", "high"}: + return True + if normalized in {"0", "false", "no", "off", "low"}: + return False + raise CapabilityError(code, f"{label} must be boolean, 0, 1, on, or off") + + +def _duration_ms(value: Any, *, code: str, label: str, maximum: int) -> int: + return _bounded_int(value, code=code, label=label, minimum=1, maximum=int(maximum)) + + +def _bounded_int(value: Any, *, code: str, label: str, minimum: int, maximum: int) -> int: + try: + number = int(value) + except (TypeError, ValueError) as exc: + raise CapabilityError(code, f"{label} must be an integer") from exc + if number < minimum or number > maximum: + raise CapabilityError(code, f"{label} must be between {minimum} and {maximum}") + return number diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/gpio.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/gpio.py index 388acd5..bd0f458 100644 --- a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/gpio.py +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/gpio.py @@ -17,8 +17,11 @@ class FakeGPIOBackend: def __init__(self): self.values: dict[int, bool] = {} - async def read(self, pin: int) -> bool: - return bool(self.values.get(int(pin), False)) + async def read(self, pin: int, *, pull: str | None = None) -> bool: + normalized_pin = int(pin) + if normalized_pin in self.values: + return bool(self.values[normalized_pin]) + return str(pull or "").strip().lower() == "up" async def write(self, pin: int, value: bool, *, duration_ms: int | None = None) -> dict[str, Any]: normalized_pin = int(pin) @@ -48,8 +51,8 @@ def __init__( self.code = code self.message = message - async def read(self, pin: int) -> bool: - del pin + async def read(self, pin: int, *, pull: str | None = None) -> bool: + del pin, pull raise CapabilityError( self.code, self.message, @@ -83,9 +86,13 @@ def __init__(self, *, pin_factory_name: str | None = None, working_dir: str | No self._input_cls = DigitalInputDevice self._output_cls = DigitalOutputDevice - async def read(self, pin: int) -> bool: - device = self._open_input(pin) + async def read(self, pin: int, *, pull: str | None = None) -> bool: + device = self._open_input(pin, pull=pull) try: + pin_obj = getattr(device, "pin", None) + raw_state = getattr(pin_obj, "state", None) + if raw_state is not None: + return bool(raw_state) return bool(device.value) finally: device.close() @@ -110,9 +117,13 @@ async def write(self, pin: int, value: bool, *, duration_ms: int | None = None) finally: device.close() - def _open_input(self, pin: int): + def _open_input(self, pin: int, *, pull: str | None = None): try: - return self._input_cls(int(pin)) + pull_up = _gpiozero_pull_up(pull) + kwargs: dict[str, Any] = {"pull_up": pull_up} + if pull_up is None: + kwargs["active_state"] = True + return self._input_cls(int(pin), **kwargs) except Exception as exc: raise _gpiozero_runtime_error(exc, pin=pin, factory_name=self._pin_factory_name) from exc @@ -303,6 +314,17 @@ def _looks_like_raspberry_pi() -> bool: return False +def _gpiozero_pull_up(pull: str | None) -> bool | None: + normalized = str(pull or "").strip().lower() + if normalized == "up": + return True + if normalized == "down": + return False + if normalized == "none": + return None + return None + + def _ensure_lgpio_working_dir(working_dir: str | None) -> None: candidate = str(working_dir or "").strip() if not candidate: diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/system_info.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/system_info.py index 6a18cdf..6464fa1 100644 --- a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/system_info.py +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/capabilities/system_info.py @@ -4,7 +4,9 @@ import platform import shutil import socket +import subprocess import sys +from importlib import metadata from pathlib import Path from typing import Any @@ -12,14 +14,20 @@ CapabilityContext, CapabilityDefinition, ) +from .gpio import ( + FakeGPIOBackend, + GpioZeroBackend, + UnavailableGPIOBackend, + _select_gpio_pin_factory_name, +) async def handle_system_info(arguments: dict[str, Any], context: CapabilityContext) -> dict[str, Any]: - del arguments, context - return collect_system_info() + del arguments + return collect_system_info(config=context.config, gpio_backend=context.gpio_backend) -def collect_system_info() -> dict[str, Any]: +def collect_system_info(*, config: Any | None = None, gpio_backend: Any | None = None) -> dict[str, Any]: disk = _disk_summary("/") return { "summary": f"{socket.gethostname()} {platform.system()} {platform.machine()}", @@ -39,6 +47,8 @@ def collect_system_info() -> dict[str, Any]: "memory": _memory_summary(), "disk": disk, "cpu_temperature_c": _cpu_temperature_c(), + "gpio": _gpio_backend_summary(gpio_backend), + "endpoint": _endpoint_summary(config), } @@ -109,6 +119,84 @@ def _cpu_temperature_c() -> float | None: return None +def _gpio_backend_summary(gpio_backend: Any | None) -> dict[str, Any]: + summary: dict[str, Any] = { + "selected_pin_factory": _select_gpio_pin_factory_name(), + "backend": None, + "available": None, + } + if gpio_backend is None: + return summary + if isinstance(gpio_backend, FakeGPIOBackend): + summary.update({"backend": "fake", "available": True}) + return summary + if isinstance(gpio_backend, UnavailableGPIOBackend): + summary.update( + { + "backend": "unavailable", + "available": False, + "code": gpio_backend.code, + "message": gpio_backend.message, + } + ) + return summary + if isinstance(gpio_backend, GpioZeroBackend): + summary.update( + { + "backend": f"gpiozero:{getattr(gpio_backend, '_pin_factory_name', 'unknown')}", + "available": True, + } + ) + return summary + summary.update({"backend": gpio_backend.__class__.__name__, "available": True}) + return summary + + +def _endpoint_summary(config: Any | None) -> dict[str, Any]: + return { + "version": _package_version(), + "git_commit": _git_commit(), + "endpoint_id": getattr(config, "endpoint_id", None), + "provider_type": getattr(config, "provider_type", None), + "executor_endpoint_id": getattr(config, "executor_endpoint_id", None), + } + + +def _package_version() -> str | None: + try: + return metadata.version("meetyou-rpi-endpoint") + except metadata.PackageNotFoundError: + return None + + +def _git_commit() -> str | None: + repo_root = _repo_root() + if repo_root is None: + return None + try: + completed = subprocess.run( + ["git", "rev-parse", "--short=12", "HEAD"], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except Exception: + return None + if completed.returncode != 0: + return None + commit = completed.stdout.strip() + return commit or None + + +def _repo_root() -> Path | None: + for parent in Path(__file__).resolve().parents: + if (parent / ".git").exists(): + return parent + return None + + def build_system_info_capability() -> CapabilityDefinition: return CapabilityDefinition( name="rpi.system.info", @@ -125,6 +213,8 @@ def build_system_info_capability() -> CapabilityDefinition: "memory": {"type": ["object", "null"]}, "disk": {"type": ["object", "null"]}, "cpu_temperature_c": {"type": ["number", "null"]}, + "gpio": {"type": "object"}, + "endpoint": {"type": "object"}, }, "required": ["summary", "hostname", "platform", "python"], }, diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/config.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/config.py index e6ceda7..532a7f4 100644 --- a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/config.py +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/config.py @@ -46,6 +46,25 @@ class SecurityConfig: gpio_write_default_duration_ms: int = 500 +@dataclass(slots=True) +class DeviceConfig: + device_id: str + type: str + name: str + pin: int + direction: str + active_high: bool = True + max_on_ms: int | None = None + requires_confirmation: bool | None = None + pull: str | None = None + + @property + def effective_requires_confirmation(self) -> bool: + if self.requires_confirmation is not None: + return bool(self.requires_confirmation) + return self.type == "relay" + + @dataclass(slots=True) class RpiEndpointConfig: core_base_url: str = "http://127.0.0.1:8000" @@ -63,6 +82,7 @@ class RpiEndpointConfig: keepalive: KeepaliveConfig = field(default_factory=KeepaliveConfig) operation: OperationConfig = field(default_factory=OperationConfig) security: SecurityConfig = field(default_factory=SecurityConfig) + devices: list[DeviceConfig] = field(default_factory=list) core_access_token: str = field(default="", repr=False) config_file_path: str = str(DEFAULT_CONFIG_PATH) @@ -193,6 +213,20 @@ def _to_int(value: Any, default: int, *, minimum: int = 0, maximum: int | None = return number +def _optional_positive_int(value: Any, *, code: str, label: str, maximum: int | None = None) -> int | None: + if value is None: + return None + try: + number = int(value) + except (TypeError, ValueError) as exc: + raise RpiConfigError(code, f"{label} must be an integer") from exc + if number <= 0: + raise RpiConfigError(code, f"{label} must be greater than 0") + if maximum is not None and number > maximum: + raise RpiConfigError(code, f"{label} must be <= {maximum}") + return number + + def _to_float(value: Any, default: float, *, minimum: float = 0.0, maximum: float | None = None) -> float: try: number = float(value) @@ -223,6 +257,117 @@ def _config_path(config_file_path: str | None) -> Path: return Path(selected) +_OUTPUT_DEVICE_TYPES = {"led", "relay", "output"} +_INPUT_DEVICE_TYPES = {"button", "input"} +_DEVICE_DIRECTIONS = {"in", "out"} +_DEVICE_PULLS = {"up", "down", "none"} + + +def _normalize_direction(value: Any) -> str: + text = str(value or "").strip().lower() + if text == "input": + return "in" + if text == "output": + return "out" + return text + + +def _normalize_pull(value: Any) -> str | None: + if value is None: + return None + text = str(value or "").strip().lower() + aliases = { + "pull_up": "up", + "pullup": "up", + "pull-down": "down", + "pull_down": "down", + "pulldown": "down", + "floating": "none", + "off": "none", + } + return aliases.get(text, text) + + +def _normalize_device_configs(value: Any, allowed_pins: list[int]) -> list[DeviceConfig]: + if value is None: + return [] + if not isinstance(value, list): + raise RpiConfigError("invalid_devices_shape", "devices must be a list") + allowed = {int(pin) for pin in allowed_pins} + seen: set[str] = set() + devices: list[DeviceConfig] = [] + for index, item in enumerate(value): + if not isinstance(item, dict): + raise RpiConfigError("invalid_device_shape", f"devices[{index}] must be an object") + device_id = str(item.get("device_id") or "").strip() + if not device_id: + raise RpiConfigError("invalid_device_id", f"devices[{index}].device_id is required") + if device_id in seen: + raise RpiConfigError("duplicate_device_id", f"duplicate Raspberry Pi device_id: {device_id}") + seen.add(device_id) + + device_type = str(item.get("type") or "").strip().lower() + if device_type not in _OUTPUT_DEVICE_TYPES | _INPUT_DEVICE_TYPES: + raise RpiConfigError("invalid_device_type", f"device {device_id} has unsupported type: {device_type}") + + direction = _normalize_direction(item.get("direction")) + if direction not in _DEVICE_DIRECTIONS: + raise RpiConfigError("invalid_device_direction", f"device {device_id} direction must be in or out") + if device_type in _OUTPUT_DEVICE_TYPES and direction != "out": + raise RpiConfigError("device_direction_mismatch", f"device {device_id} type {device_type} requires direction=out") + if device_type in _INPUT_DEVICE_TYPES and direction != "in": + raise RpiConfigError("device_direction_mismatch", f"device {device_id} type {device_type} requires direction=in") + + try: + pin = int(item.get("pin")) + except (TypeError, ValueError) as exc: + raise RpiConfigError("invalid_device_pin", f"device {device_id} pin must be an integer") from exc + if pin not in allowed: + raise RpiConfigError( + "device_pin_not_allowed", + f"device {device_id} pin {pin} is not listed in security.gpio_allowed_pins", + ) + + pull = _normalize_pull(item.get("pull")) + if direction == "out" and pull is not None: + raise RpiConfigError("invalid_device_pull", f"output device {device_id} must not configure pull") + if direction == "in" and pull is not None and pull not in _DEVICE_PULLS: + raise RpiConfigError("invalid_device_pull", f"input device {device_id} pull must be up, down, or none") + + max_on_ms = _optional_positive_int( + item.get("max_on_ms"), + code="invalid_device_max_on_ms", + label=f"device {device_id} max_on_ms", + maximum=60_000, + ) + if direction == "in" and max_on_ms is not None: + raise RpiConfigError("invalid_device_max_on_ms", f"input device {device_id} must not configure max_on_ms") + + requires_confirmation = None + if "requires_confirmation" in item: + requires_confirmation = _to_bool(item.get("requires_confirmation"), default=device_type == "relay") + if direction == "in" and requires_confirmation is not None: + raise RpiConfigError( + "invalid_device_confirmation", + f"input device {device_id} must not configure requires_confirmation", + ) + + devices.append( + DeviceConfig( + device_id=device_id, + type=device_type, + name=str(item.get("name") or device_id).strip() or device_id, + pin=pin, + direction=direction, + active_high=_to_bool(item.get("active_high"), default=True), + max_on_ms=max_on_ms, + requires_confirmation=requires_confirmation, + pull=pull, + ) + ) + return devices + + def load_rpi_endpoint_config(config_file_path: str | None = None) -> RpiEndpointConfig: file_path = _config_path(config_file_path) env_root = file_path.parent.parent if file_path.parent.name == "user" else file_path.parent @@ -252,6 +397,19 @@ def load_rpi_endpoint_config(config_file_path: str | None = None) -> RpiEndpoint default=False, ) + security = SecurityConfig( + sandbox_dir=str(security_payload.get("sandbox_dir") or "/var/lib/meetyou-rpi/sandbox").strip(), + safe_shell_enabled=safe_shell_enabled, + safe_shell_allowlist=list(security_payload.get("safe_shell_allowlist") or []), + gpio_allowed_pins=_int_list(security_payload.get("gpio_allowed_pins"), [17, 27, 22]), + gpio_write_default_duration_ms=_to_int( + security_payload.get("gpio_write_default_duration_ms"), + 500, + minimum=0, + maximum=60_000, + ), + ) + return RpiEndpointConfig( core_base_url=str( os.environ.get("MEETYOU_RPI_CORE_BASE_URL") @@ -290,19 +448,8 @@ def load_rpi_endpoint_config(config_file_path: str | None = None) -> RpiEndpoint default_timeout_seconds=default_timeout, max_timeout_seconds=max_timeout, ), - security=SecurityConfig( - sandbox_dir=str(security_payload.get("sandbox_dir") or "/var/lib/meetyou-rpi/sandbox").strip(), - safe_shell_enabled=safe_shell_enabled, - safe_shell_allowlist=list(security_payload.get("safe_shell_allowlist") or []), - gpio_allowed_pins=_int_list(security_payload.get("gpio_allowed_pins"), [17, 27, 22]), - gpio_write_default_duration_ms=_to_int( - security_payload.get("gpio_write_default_duration_ms"), - 500, - minimum=0, - maximum=60_000, - ), - ), + security=security, + devices=_normalize_device_configs(payload.get("devices"), security.gpio_allowed_pins), core_access_token=_resolve_token(payload, endpoint_token_env), config_file_path=str(file_path), ) - diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/connection.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/connection.py index c1f4a92..1d2f100 100644 --- a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/connection.py +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/connection.py @@ -110,6 +110,13 @@ def build_hello_message(self) -> dict[str, Any]: return build_hello(self.config) def build_tools_snapshot_message(self, *, revision: int) -> dict[str, Any]: + capability_names = self.registry.names() + self._logger.info( + "%s advertising %d capabilities: %s", + self.runtime_label, + len(capability_names), + ", ".join(capability_names), + ) return build_tools_snapshot( self.config, revision=revision, diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/devices.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/devices.py new file mode 100644 index 0000000..b17b8b0 --- /dev/null +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/devices.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Iterable + +from .config import DeviceConfig, RpiConfigError + + +class DeviceRegistry: + def __init__(self, devices: Iterable[DeviceConfig], *, allowed_pins: list[int]): + self._allowed_pins = {int(pin) for pin in allowed_pins} + self._devices: dict[str, DeviceConfig] = {} + for device in devices or []: + self._register(device) + + @classmethod + def from_config(cls, config) -> "DeviceRegistry": + return cls( + getattr(config, "devices", []), + allowed_pins=list(getattr(getattr(config, "security", None), "gpio_allowed_pins", []) or []), + ) + + def list(self) -> list[DeviceConfig]: + return [self._devices[device_id] for device_id in sorted(self._devices)] + + def get(self, device_id: str) -> DeviceConfig | None: + return self._devices.get(str(device_id or "").strip()) + + def requires_confirmation_for_writes(self) -> bool: + return any( + device.direction == "out" and device.effective_requires_confirmation + for device in self._devices.values() + ) + + def _register(self, device: DeviceConfig) -> None: + device_id = str(device.device_id or "").strip() + if not device_id: + raise RpiConfigError("invalid_device_id", "device_id is required") + if device_id in self._devices: + raise RpiConfigError("duplicate_device_id", f"duplicate Raspberry Pi device_id: {device_id}") + if int(device.pin) not in self._allowed_pins: + raise RpiConfigError( + "device_pin_not_allowed", + f"device {device_id} pin {int(device.pin)} is not listed in security.gpio_allowed_pins", + ) + if device.direction not in {"in", "out"}: + raise RpiConfigError("invalid_device_direction", f"device {device_id} direction must be in or out") + if device.direction == "in" and device.type in {"led", "relay", "output"}: + raise RpiConfigError("device_direction_mismatch", f"device {device_id} type {device.type} requires direction=out") + if device.direction == "out" and device.type in {"button", "input"}: + raise RpiConfigError("device_direction_mismatch", f"device {device_id} type {device.type} requires direction=in") + self._devices[device_id] = device + + +def device_public_dict(device: DeviceConfig) -> dict[str, object]: + payload: dict[str, object] = { + "device_id": device.device_id, + "type": device.type, + "name": device.name, + "pin": int(device.pin), + "direction": device.direction, + "active_high": bool(device.active_high), + } + if device.max_on_ms is not None: + payload["max_on_ms"] = int(device.max_on_ms) + if device.direction == "out": + payload["requires_confirmation"] = device.effective_requires_confirmation + if device.pull is not None: + payload["pull"] = device.pull + return payload diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/health.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/health.py new file mode 100644 index 0000000..ab68dcc --- /dev/null +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/health.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import argparse +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from .capabilities.base import CapabilityError +from .capabilities.gpio import ( + _configure_gpiozero_pin_factory, + _looks_like_raspberry_pi, + _select_gpio_pin_factory_name, +) +from .config import ( + DEFAULT_CONFIG_PATH, + DEFAULT_ENDPOINT_TOKEN_ENV, + RpiConfigError, + RpiEndpointConfig, + load_rpi_endpoint_config, +) + + +PASS = "PASS" +WARN = "WARN" +FAIL = "FAIL" +_VALID_STATUSES = {PASS, WARN, FAIL} + + +@dataclass(frozen=True, slots=True) +class HealthCheckResult: + name: str + status: str + message: str + + def __post_init__(self) -> None: + if self.status not in _VALID_STATUSES: + raise ValueError(f"invalid health status: {self.status}") + + +def load_env_file(env_file_path: str | Path | None, *, override: bool = False) -> HealthCheckResult | None: + if not env_file_path: + return None + path = Path(env_file_path) + if not path.exists(): + return HealthCheckResult("env_file", WARN, f"environment file not found: {path}") + try: + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + if line.startswith("export "): + line = line[len("export ") :].strip() + key, value = line.split("=", 1) + key = key.strip() + if not key: + continue + if not override and key in os.environ: + continue + os.environ[key] = _unquote_env_value(value.strip()) + except OSError as exc: + return HealthCheckResult("env_file", FAIL, f"failed to read environment file: {path}: {exc}") + return HealthCheckResult("env_file", PASS, f"loaded environment file: {path}") + + +def run_health_checks( + *, + config_path: str | Path | None = None, + env_file_path: str | Path | None = None, + service_name: str = "meetyou-rpi-endpoint", +) -> list[HealthCheckResult]: + results: list[HealthCheckResult] = [] + env_result = load_env_file(env_file_path) + if env_result is not None: + results.append(env_result) + + selected_config_path = Path(config_path or os.environ.get("MEETYOU_RPI_CONFIG") or DEFAULT_CONFIG_PATH) + results.append(_check_config_file(selected_config_path)) + + config: RpiEndpointConfig | None = None + try: + config = load_rpi_endpoint_config(str(selected_config_path)) + except RpiConfigError as exc: + results.append(HealthCheckResult("config_load", FAIL, f"{exc.code}: {exc.message}")) + except Exception as exc: + results.append(HealthCheckResult("config_load", FAIL, f"unexpected config load failure: {type(exc).__name__}: {exc}")) + + if config is None: + results.append(_check_systemd_service(service_name)) + return results + + results.append(_check_token_env(config)) + results.append(_check_sandbox_writable(config.security.sandbox_dir)) + results.append(_check_gpio_backend(config)) + results.append(_check_gpio_group()) + results.append(_check_gpiochip_permissions()) + results.append(_check_systemd_service(service_name)) + return results + + +def render_health_results(results: Iterable[HealthCheckResult]) -> str: + return "\n".join(f"[{item.status}] {item.name}: {item.message}" for item in results) + + +def health_exit_code(results: Iterable[HealthCheckResult]) -> int: + return 1 if any(item.status == FAIL for item in results) else 0 + + +def _check_config_file(path: Path) -> HealthCheckResult: + if path.exists() and path.is_file(): + return HealthCheckResult("config_file", PASS, f"found config file: {path}") + return HealthCheckResult("config_file", FAIL, f"config file is missing: {path}") + + +def _check_token_env(config: RpiEndpointConfig) -> HealthCheckResult: + for env_name in _token_env_names(config.endpoint_token_env): + if str(os.environ.get(env_name, "")).strip(): + return HealthCheckResult( + "token_env", + PASS, + f"token environment variable is set: {env_name} (value redacted)", + ) + expected = ", ".join(_token_env_names(config.endpoint_token_env)) + return HealthCheckResult("token_env", FAIL, f"no endpoint token environment variable is set; checked: {expected}") + + +def _check_sandbox_writable(sandbox_dir: str) -> HealthCheckResult: + path = Path(sandbox_dir) + try: + path.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix=".meetyou-health-", dir=path, delete=True) as handle: + handle.write(b"ok") + handle.flush() + except OSError as exc: + return HealthCheckResult("sandbox_dir", FAIL, f"sandbox_dir is not writable: {path}: {exc}") + return HealthCheckResult("sandbox_dir", PASS, f"sandbox_dir is writable: {path}") + + +def _check_gpio_backend(config: RpiEndpointConfig) -> HealthCheckResult: + if _env_truthy("MEETYOU_RPI_FAKE_GPIO"): + return HealthCheckResult("gpio_backend", WARN, "fake GPIO is enabled; hardware GPIO is not validated") + + selected = _select_gpio_pin_factory_name() + is_pi = _looks_like_raspberry_pi() + configured = str(os.environ.get("MEETYOU_RPI_GPIO_PIN_FACTORY") or "").strip().lower() + if is_pi and configured != "lgpio": + return HealthCheckResult( + "gpio_backend", + FAIL, + "MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio is required in the service environment for Raspberry Pi 5 GPIO", + ) + if selected != "lgpio": + status = FAIL if is_pi else WARN + return HealthCheckResult( + "gpio_backend", + status, + f"selected GPIO pin factory is {selected}; Raspberry Pi 5 production should use lgpio", + ) + + original_cwd = os.getcwd() + try: + from gpiozero import Device + + label = _configure_gpiozero_pin_factory( + Device, + "lgpio", + working_dir=config.security.sandbox_dir, + ) + except CapabilityError as exc: + return HealthCheckResult("gpio_backend", FAIL, f"{exc.code}: {exc.message}") + except Exception as exc: + return HealthCheckResult("gpio_backend", FAIL, f"GPIO lgpio backend check failed: {type(exc).__name__}: {exc}") + finally: + try: + os.chdir(original_cwd) + except OSError: + pass + return HealthCheckResult("gpio_backend", PASS, f"GPIO backend available: gpiozero:{label}") + + +def _check_gpio_group(group_name: str = "gpio") -> HealthCheckResult: + if platform.system().lower() != "linux": + return HealthCheckResult("gpio_group", WARN, "gpio group check is only applicable on Linux") + try: + import grp + import pwd + + group = grp.getgrnam(group_name) + user = pwd.getpwuid(os.geteuid()).pw_name + gids = set(os.getgroups()) + gids.add(os.getegid()) + except KeyError: + return HealthCheckResult("gpio_group", FAIL, f"required group does not exist: {group_name}") + except Exception as exc: + return HealthCheckResult("gpio_group", WARN, f"could not inspect current user groups: {type(exc).__name__}: {exc}") + + if group.gr_gid in gids or user in group.gr_mem: + return HealthCheckResult("gpio_group", PASS, f"current user {user} is in group {group_name}") + return HealthCheckResult( + "gpio_group", + FAIL, + f"current user {user} is not in group {group_name}; systemd should set SupplementaryGroups=gpio", + ) + + +def _check_gpiochip_permissions() -> HealthCheckResult: + if platform.system().lower() != "linux": + return HealthCheckResult("gpiochip_permissions", WARN, "/dev/gpiochip* check is only applicable on Linux") + paths = sorted(Path("/dev").glob("gpiochip*")) + if not paths: + status = FAIL if _looks_like_raspberry_pi() else WARN + return HealthCheckResult("gpiochip_permissions", status, "no /dev/gpiochip* devices found") + + accessible = [path for path in paths if os.access(path, os.R_OK | os.W_OK)] + if accessible: + names = ", ".join(path.name for path in accessible[:4]) + return HealthCheckResult("gpiochip_permissions", PASS, f"current user can read/write gpiochip devices: {names}") + + names = ", ".join(path.name for path in paths[:4]) + return HealthCheckResult( + "gpiochip_permissions", + FAIL, + f"found {names} but current user lacks read/write permission; can not open gpiochip means a device permission/group problem", + ) + + +def _check_systemd_service(service_name: str) -> HealthCheckResult: + systemctl = shutil.which("systemctl") + if not systemctl: + return HealthCheckResult("systemd_service", WARN, "systemctl not found; service state not checked") + try: + completed = subprocess.run( + [systemctl, "is-active", service_name], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + except Exception as exc: + return HealthCheckResult("systemd_service", WARN, f"systemd service check failed: {type(exc).__name__}: {exc}") + + state = (completed.stdout or completed.stderr or "unknown").strip() + if state == "active": + return HealthCheckResult("systemd_service", PASS, f"{service_name}.service is active") + if state == "failed": + return HealthCheckResult("systemd_service", FAIL, f"{service_name}.service is failed") + return HealthCheckResult("systemd_service", WARN, f"{service_name}.service is {state or 'unknown'}") + + +def _token_env_names(endpoint_token_env: str) -> list[str]: + names = [ + DEFAULT_ENDPOINT_TOKEN_ENV, + str(endpoint_token_env or "").strip(), + "MEETYOU_CLIENT_ACCESS_TOKEN", + "MEETYOU_GATEWAY_ACCESS_TOKEN", + ] + result: list[str] = [] + for name in names: + if name and name not in result: + result.append(name) + return result + + +def _unquote_env_value(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def _env_truthy(name: str) -> bool: + return str(os.environ.get(name, "")).strip().lower() in {"1", "true", "yes", "on"} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="MeetYou Raspberry Pi Endpoint health check") + parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH), help="Path to rpi endpoint JSON config") + parser.add_argument("--env-file", default="", help="Optional systemd EnvironmentFile to load before checks") + parser.add_argument("--service-name", default="meetyou-rpi-endpoint", help="systemd service name without .service") + return parser + + +def main(argv: list[str] | None = None) -> None: + args = build_parser().parse_args(sys.argv[1:] if argv is None else argv) + results = run_health_checks( + config_path=args.config, + env_file_path=args.env_file or None, + service_name=args.service_name, + ) + print(render_health_results(results)) + raise SystemExit(health_exit_code(results)) + + +if __name__ == "__main__": + main() diff --git a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/registry.py b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/registry.py index f491f49..f362523 100644 --- a/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/registry.py +++ b/endpoint_providers/raspberry_pi/meetyou_rpi_endpoint/registry.py @@ -7,6 +7,14 @@ CapabilityDefinition, CapabilityError, ) +from .capabilities.device import ( + build_button_read_capability, + build_device_blink_capability, + build_device_list_capability, + build_device_pulse_capability, + build_device_set_capability, + build_device_status_capability, +) from .capabilities.echo import build_echo_capability from .capabilities.gpio import ( build_gpio_backend, @@ -19,6 +27,7 @@ from .capabilities.system_info import ( build_system_info_capability, ) +from .devices import DeviceRegistry from .security import normalize_safe_shell_allowlist @@ -67,11 +76,25 @@ def build_default_registry(config, *, gpio_backend=None, force_fake_gpio: bool = working_dir=getattr(getattr(config, "security", None), "sandbox_dir", ""), ) ) - registry = CapabilityRegistry(CapabilityContext(config=config, gpio_backend=backend)) + device_registry = DeviceRegistry.from_config(config) + device_writes_require_confirmation = device_registry.requires_confirmation_for_writes() + registry = CapabilityRegistry( + CapabilityContext( + config=config, + gpio_backend=backend, + device_registry=device_registry, + ) + ) registry.register(build_echo_capability()) registry.register(build_system_info_capability()) registry.register(build_gpio_read_capability()) registry.register(build_gpio_write_capability()) + registry.register(build_device_list_capability()) + registry.register(build_device_status_capability()) + registry.register(build_device_set_capability(requires_confirmation=device_writes_require_confirmation)) + registry.register(build_device_pulse_capability(requires_confirmation=device_writes_require_confirmation)) + registry.register(build_device_blink_capability(requires_confirmation=device_writes_require_confirmation)) + registry.register(build_button_read_capability()) allowlist = normalize_safe_shell_allowlist(config.security.safe_shell_allowlist) if config.security.safe_shell_enabled and allowlist: registry.register(build_safe_shell_capability()) diff --git a/endpoint_providers/raspberry_pi/pyproject.toml b/endpoint_providers/raspberry_pi/pyproject.toml index ca59e24..b017f33 100644 --- a/endpoint_providers/raspberry_pi/pyproject.toml +++ b/endpoint_providers/raspberry_pi/pyproject.toml @@ -20,6 +20,7 @@ gpio = [ [project.scripts] meetyou-rpi-endpoint = "meetyou_rpi_endpoint.main:main" +meetyou-rpi-health = "meetyou_rpi_endpoint.health:main" [tool.setuptools.packages.find] where = ["."] diff --git a/endpoint_providers/raspberry_pi/tests/test_config.py b/endpoint_providers/raspberry_pi/tests/test_config.py index fef4fed..301838a 100644 --- a/endpoint_providers/raspberry_pi/tests/test_config.py +++ b/endpoint_providers/raspberry_pi/tests/test_config.py @@ -30,6 +30,8 @@ def test_loads_example_config(self): self.assertEqual(config.connect_path, "/endpoint/ws") self.assertEqual(config.security.gpio_allowed_pins, [17, 27, 22]) self.assertFalse(config.security.safe_shell_enabled) + self.assertEqual([device.device_id for device in config.devices], ["desk_led", "relay_1", "button_1"]) + self.assertTrue(config.devices[1].effective_requires_confirmation) def test_env_overrides_work(self): with TemporaryDirectory() as tmp, patch.dict( diff --git a/endpoint_providers/raspberry_pi/tests/test_devices.py b/endpoint_providers/raspberry_pi/tests/test_devices.py new file mode 100644 index 0000000..a57a2a4 --- /dev/null +++ b/endpoint_providers/raspberry_pi/tests/test_devices.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.config import ( + DeviceConfig, + RpiConfigError, + RpiEndpointConfig, + SecurityConfig, + load_rpi_endpoint_config, +) +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.registry import build_default_registry +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.runtime.operation_runner import OperationRunner +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.runtime.result_models import OperationRequest + + +class DeviceConfigTests(unittest.TestCase): + def _write_config(self, root: Path, devices: list[dict]) -> Path: + path = root / "rpi.json" + path.write_text( + json.dumps( + { + "security": {"gpio_allowed_pins": [17, 27, 22], "safe_shell_enabled": False}, + "devices": devices, + } + ), + encoding="utf-8", + ) + return path + + def test_device_config_loads_successfully(self): + with TemporaryDirectory() as tmp: + path = self._write_config( + Path(tmp), + [ + { + "device_id": "desk_led", + "type": "led", + "name": "Desk LED", + "pin": 17, + "direction": "out", + "active_high": True, + }, + { + "device_id": "button_1", + "type": "button", + "name": "Desk Button", + "pin": 22, + "direction": "in", + "active_high": False, + "pull": "up", + }, + ], + ) + + config = load_rpi_endpoint_config(str(path)) + + self.assertEqual([device.device_id for device in config.devices], ["desk_led", "button_1"]) + self.assertEqual(config.devices[1].pull, "up") + + def test_duplicate_device_id_rejected(self): + with TemporaryDirectory() as tmp: + path = self._write_config( + Path(tmp), + [ + {"device_id": "desk_led", "type": "led", "name": "Desk LED", "pin": 17, "direction": "out"}, + {"device_id": "desk_led", "type": "led", "name": "Other LED", "pin": 27, "direction": "out"}, + ], + ) + + with self.assertRaises(RpiConfigError) as raised: + load_rpi_endpoint_config(str(path)) + + self.assertEqual(raised.exception.code, "duplicate_device_id") + + def test_device_pin_outside_allowlist_rejected(self): + with TemporaryDirectory() as tmp: + path = self._write_config( + Path(tmp), + [ + {"device_id": "desk_led", "type": "led", "name": "Desk LED", "pin": 5, "direction": "out"}, + ], + ) + + with self.assertRaises(RpiConfigError) as raised: + load_rpi_endpoint_config(str(path)) + + self.assertEqual(raised.exception.code, "device_pin_not_allowed") + + +class DeviceCapabilityTests(unittest.IsolatedAsyncioTestCase): + def _registry(self, devices: list[DeviceConfig]): + config = RpiEndpointConfig( + core_access_token="super-secret-token", + security=SecurityConfig( + gpio_allowed_pins=[17, 27, 22], + gpio_write_default_duration_ms=0, + ), + devices=devices, + ) + registry = build_default_registry(config, force_fake_gpio=True) + return registry, OperationRunner(registry) + + def _desk_led(self, **overrides) -> DeviceConfig: + values = { + "device_id": "desk_led", + "type": "led", + "name": "Desk LED", + "pin": 17, + "direction": "out", + "active_high": True, + } + values.update(overrides) + return DeviceConfig(**values) + + async def test_device_list_does_not_expose_secrets(self): + registry, runner = self._registry([self._desk_led()]) + + final = await runner.run( + OperationRequest( + operation_id="op-device-list", + call_id="call-device-list", + capability_name="rpi.device.list", + arguments={}, + ) + ) + + self.assertTrue(final.succeeded) + self.assertIn("rpi.device.list", registry.names()) + self.assertIn("desk_led", json.dumps(final.payload)) + self.assertNotIn("super-secret-token", json.dumps(final.payload)) + + async def test_device_set_works_with_fake_gpio(self): + registry, runner = self._registry([self._desk_led()]) + + set_result = await runner.run( + OperationRequest( + operation_id="op-device-set", + call_id="call-device-set", + capability_name="rpi.device.set", + arguments={"device_id": "desk_led", "value": True}, + ) + ) + status_result = await runner.run( + OperationRequest( + operation_id="op-device-status", + call_id="call-device-status", + capability_name="rpi.device.status", + arguments={"device_id": "desk_led"}, + ) + ) + + self.assertTrue(set_result.succeeded) + self.assertEqual(set_result.payload["backend"], "fake") + self.assertTrue(status_result.payload["value"]) + self.assertTrue(registry.get("rpi.device.set") is not None) + + async def test_device_pulse_resets_output_after_duration(self): + _, runner = self._registry([self._desk_led(max_on_ms=100)]) + + pulse_result = await runner.run( + OperationRequest( + operation_id="op-device-pulse", + call_id="call-device-pulse", + capability_name="rpi.device.pulse", + arguments={"device_id": "desk_led", "duration_ms": 1}, + ) + ) + status_result = await runner.run( + OperationRequest( + operation_id="op-device-status-after-pulse", + call_id="call-device-status-after-pulse", + capability_name="rpi.device.status", + arguments={"device_id": "desk_led"}, + ) + ) + + self.assertTrue(pulse_result.succeeded) + self.assertTrue(pulse_result.payload["reset_performed"]) + self.assertFalse(status_result.payload["value"]) + + async def test_device_blink_enforces_count_limit(self): + _, runner = self._registry([self._desk_led(max_on_ms=100)]) + + final = await runner.run( + OperationRequest( + operation_id="op-device-blink-count", + call_id="call-device-blink-count", + capability_name="rpi.device.blink", + arguments={"device_id": "desk_led", "count": 21, "interval_ms": 1}, + ) + ) + + self.assertEqual(final.status, "failed") + self.assertEqual(final.error["code"], "invalid_device_blink") + + async def test_device_blink_enforces_duration_limit(self): + _, runner = self._registry([self._desk_led(max_on_ms=100)]) + + final = await runner.run( + OperationRequest( + operation_id="op-device-blink-duration", + call_id="call-device-blink-duration", + capability_name="rpi.device.blink", + arguments={"device_id": "desk_led", "count": 2, "interval_ms": 101}, + ) + ) + + self.assertEqual(final.status, "failed") + self.assertEqual(final.error["code"], "invalid_device_blink") + + async def test_input_device_cannot_be_set(self): + _, runner = self._registry( + [ + DeviceConfig( + device_id="button_1", + type="button", + name="Button", + pin=22, + direction="in", + active_high=False, + pull="up", + ) + ] + ) + + final = await runner.run( + OperationRequest( + operation_id="op-device-set-button", + call_id="call-device-set-button", + capability_name="rpi.device.set", + arguments={"device_id": "button_1", "value": True}, + ) + ) + + self.assertEqual(final.status, "failed") + self.assertEqual(final.error["code"], "device_permission_denied") + + async def test_button_read_uses_input_device(self): + _, runner = self._registry( + [ + DeviceConfig( + device_id="button_1", + type="button", + name="Button", + pin=22, + direction="in", + active_high=False, + pull="up", + ) + ] + ) + + final = await runner.run( + OperationRequest( + operation_id="op-button-read", + call_id="call-button-read", + capability_name="rpi.button.read", + arguments={"device_id": "button_1"}, + ) + ) + + self.assertTrue(final.succeeded) + self.assertFalse(final.payload["value"]) + self.assertTrue(final.payload["raw_value"]) + + def test_relay_requires_confirmation_by_default(self): + registry, _ = self._registry( + [ + DeviceConfig( + device_id="relay_1", + type="relay", + name="Relay", + pin=27, + direction="out", + active_high=True, + ) + ] + ) + + definitions = {item["tool_key"]: item for item in registry.tool_definitions()} + + self.assertTrue(definitions["rpi.device.set"]["requires_confirmation"]) + self.assertTrue(definitions["rpi.device.pulse"]["requires_confirmation"]) + self.assertTrue(definitions["rpi.device.blink"]["requires_confirmation"]) + + def test_relay_confirmation_can_be_explicitly_disabled(self): + registry, _ = self._registry( + [ + DeviceConfig( + device_id="relay_1", + type="relay", + name="Relay", + pin=27, + direction="out", + active_high=True, + requires_confirmation=False, + ) + ] + ) + + definitions = {item["tool_key"]: item for item in registry.tool_definitions()} + + self.assertFalse(definitions["rpi.device.set"]["requires_confirmation"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/endpoint_providers/raspberry_pi/tests/test_gpio.py b/endpoint_providers/raspberry_pi/tests/test_gpio.py index f10321a..668c356 100644 --- a/endpoint_providers/raspberry_pi/tests/test_gpio.py +++ b/endpoint_providers/raspberry_pi/tests/test_gpio.py @@ -1,10 +1,12 @@ from __future__ import annotations import unittest +from types import SimpleNamespace from unittest.mock import patch from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.capabilities.base import CapabilityError from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.capabilities.gpio import ( + GpioZeroBackend, UnavailableGPIOBackend, _configure_gpiozero_pin_factory, _ensure_lgpio_working_dir, @@ -69,6 +71,30 @@ async def test_fake_backend_write_then_read(self): self.assertEqual(write_result.payload["backend"], "fake") self.assertTrue(read_result.payload["value"]) + async def test_fake_backend_duration_write_resets_low(self): + runner, _ = self._runner() + + write_result = await runner.run( + OperationRequest( + operation_id="op-gpio-pulse", + call_id="call-gpio-pulse", + capability_name="rpi.gpio.write", + arguments={"pin": 17, "value": True, "duration_ms": 1}, + ) + ) + read_result = await runner.run( + OperationRequest( + operation_id="op-gpio-read-after-pulse", + call_id="call-gpio-read-after-pulse", + capability_name="rpi.gpio.read", + arguments={"pin": 17}, + ) + ) + + self.assertTrue(write_result.succeeded) + self.assertTrue(write_result.payload["reset_performed"]) + self.assertFalse(read_result.payload["value"]) + async def test_unavailable_backend_preserves_reason(self): backend = UnavailableGPIOBackend(code="gpio_lgpio_unavailable", message="install lgpio") @@ -78,6 +104,60 @@ async def test_unavailable_backend_preserves_reason(self): self.assertEqual(raised.exception.code, "gpio_lgpio_unavailable") self.assertIn("install lgpio", raised.exception.message) + async def test_default_unavailable_backend_message_names_gpiozero_and_lgpio(self): + backend = UnavailableGPIOBackend() + + with self.assertRaises(CapabilityError) as raised: + await backend.read(17) + + self.assertEqual(raised.exception.code, "gpio_unavailable") + self.assertIn("gpiozero", raised.exception.message) + self.assertIn("lgpio", raised.exception.message) + + async def test_gpiozero_read_sets_active_state_for_floating_inputs(self): + calls: list[dict] = [] + + class FakeInput: + def __init__(self, pin, **kwargs): + calls.append({"pin": pin, "kwargs": dict(kwargs)}) + self.pin = SimpleNamespace(state=True) + self.value = False + self.closed = False + + def close(self): + self.closed = True + + backend = object.__new__(GpioZeroBackend) + backend._input_cls = FakeInput + backend._pin_factory_name = "lgpio" + + value = await backend.read(17) + + self.assertTrue(value) + self.assertEqual(calls, [{"pin": 17, "kwargs": {"pull_up": None, "active_state": True}}]) + + def test_gpiozero_pull_configured_inputs_do_not_override_active_state(self): + calls: list[dict] = [] + + class FakeInput: + def __init__(self, pin, **kwargs): + calls.append({"pin": pin, "kwargs": dict(kwargs)}) + + backend = object.__new__(GpioZeroBackend) + backend._input_cls = FakeInput + backend._pin_factory_name = "lgpio" + + backend._open_input(17, pull="up") + backend._open_input(27, pull="down") + + self.assertEqual( + calls, + [ + {"pin": 17, "kwargs": {"pull_up": True}}, + {"pin": 27, "kwargs": {"pull_up": False}}, + ], + ) + def test_gpio_pin_factory_env_override(self): with patch.dict("os.environ", {"MEETYOU_RPI_GPIO_PIN_FACTORY": "lgpio"}, clear=True): self.assertEqual(_select_gpio_pin_factory_name(), "lgpio") diff --git a/endpoint_providers/raspberry_pi/tests/test_health.py b/endpoint_providers/raspberry_pi/tests/test_health.py new file mode 100644 index 0000000..c29bce4 --- /dev/null +++ b/endpoint_providers/raspberry_pi/tests/test_health.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +import os +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.health import ( + HealthCheckResult, + _check_gpio_backend, + load_env_file, + render_health_results, + run_health_checks, +) +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.config import RpiEndpointConfig, SecurityConfig + + +class HealthCheckTests(unittest.TestCase): + def _write_config(self, root: Path) -> Path: + path = root / "rpi.json" + path.write_text( + json.dumps( + { + "endpoint_token_env": "MEETYOU_RPI_ENDPOINT_TOKEN", + "security": { + "sandbox_dir": str(root / "sandbox"), + "gpio_allowed_pins": [17], + }, + } + ), + encoding="utf-8", + ) + return path + + def test_env_file_loader_does_not_render_token_value(self): + with TemporaryDirectory() as tmp: + env_file = Path(tmp) / "rpi.env" + env_file.write_text("MEETYOU_RPI_ENDPOINT_TOKEN=super-secret-token\n", encoding="utf-8") + + with patch.dict(os.environ, {}, clear=True): + result = load_env_file(env_file) + rendered = render_health_results([result]) + + self.assertEqual(os.environ["MEETYOU_RPI_ENDPOINT_TOKEN"], "super-secret-token") + + self.assertIn("env_file", rendered) + self.assertNotIn("super-secret-token", rendered) + + def test_health_output_never_prints_token_value(self): + with TemporaryDirectory() as tmp, patch.dict( + os.environ, + {"MEETYOU_RPI_ENDPOINT_TOKEN": "super-secret-token"}, + clear=True, + ): + config_path = self._write_config(Path(tmp)) + patched = [ + patch( + "endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.health._check_gpio_backend", + return_value=HealthCheckResult("gpio_backend", "PASS", "mock gpio ok"), + ), + patch( + "endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.health._check_gpio_group", + return_value=HealthCheckResult("gpio_group", "PASS", "mock group ok"), + ), + patch( + "endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.health._check_gpiochip_permissions", + return_value=HealthCheckResult("gpiochip_permissions", "PASS", "mock gpiochip ok"), + ), + patch( + "endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.health._check_systemd_service", + return_value=HealthCheckResult("systemd_service", "PASS", "mock service ok"), + ), + ] + with patched[0], patched[1], patched[2], patched[3]: + rendered = render_health_results(run_health_checks(config_path=config_path)) + + self.assertIn("[PASS] token_env", rendered) + self.assertIn("MEETYOU_RPI_ENDPOINT_TOKEN", rendered) + self.assertIn("value redacted", rendered) + self.assertNotIn("super-secret-token", rendered) + + def test_raspberry_pi_health_requires_explicit_lgpio_env(self): + config = RpiEndpointConfig(security=SecurityConfig(gpio_allowed_pins=[17])) + + with patch.dict(os.environ, {}, clear=True), patch( + "endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.health._looks_like_raspberry_pi", + return_value=True, + ): + result = _check_gpio_backend(config) + + self.assertEqual(result.status, "FAIL") + self.assertEqual(result.name, "gpio_backend") + self.assertIn("MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio", result.message) + + +if __name__ == "__main__": + unittest.main() diff --git a/endpoint_providers/raspberry_pi/tests/test_system_info.py b/endpoint_providers/raspberry_pi/tests/test_system_info.py index 6670a42..b2afe91 100644 --- a/endpoint_providers/raspberry_pi/tests/test_system_info.py +++ b/endpoint_providers/raspberry_pi/tests/test_system_info.py @@ -1,20 +1,39 @@ from __future__ import annotations import unittest +from unittest.mock import patch +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.capabilities.gpio import FakeGPIOBackend from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.capabilities.system_info import collect_system_info +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.config import RpiEndpointConfig class SystemInfoTests(unittest.TestCase): def test_collect_system_info_does_not_require_temperature(self): - payload = collect_system_info() + with patch( + "endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.capabilities.system_info._cpu_temperature_c", + return_value=None, + ): + payload = collect_system_info() self.assertIn("hostname", payload) self.assertIn("platform", payload) self.assertIn("python", payload) self.assertIn("cpu_temperature_c", payload) + self.assertIsNone(payload["cpu_temperature_c"]) + + def test_collect_system_info_reports_endpoint_and_gpio_metadata(self): + payload = collect_system_info( + config=RpiEndpointConfig(endpoint_id="pi-lab"), + gpio_backend=FakeGPIOBackend(), + ) + + self.assertEqual(payload["endpoint"]["endpoint_id"], "pi-lab") + self.assertIn("version", payload["endpoint"]) + self.assertIn("git_commit", payload["endpoint"]) + self.assertEqual(payload["gpio"]["backend"], "fake") + self.assertTrue(payload["gpio"]["available"]) if __name__ == "__main__": unittest.main() - diff --git a/prompt/SKILL/mode-general b/prompt/SKILL/mode-general index 7b5e7c2..5df3f3c 100644 --- a/prompt/SKILL/mode-general +++ b/prompt/SKILL/mode-general @@ -8,7 +8,8 @@ Tool strategy: - Start with shared basic tools first: search_knowledge, search_memory, search_web, read_web_page, remember_knowledge, ask_human, emit_progress_notice, and get_current_system_time. - Before time-consuming work such as web/page reading, research, local file or workspace operations, endpoint tool calls, endpoint messaging, or other slow I/O, call emit_progress_notice first with a short status update. - Use research_topic or inspect_page only when a higher-level chain is clearly more efficient than shared primitives. -- Endpoint/device listing requests are not memory questions. For endpoint tools, workspace devices, online devices, executable targets, or provider capability lists, call list_active_endpoints or list_endpoint_tool_targets first. Prefer the returned tool_target_lines and executable_tools_by_endpoint fields, then compact_endpoints for display details; report endpoint_id, provider_type, status, workspace_ids, and executable_tools without silently dropping entries. +- Endpoint/device listing requests are not memory questions. For endpoint tools, workspace devices, online devices, executable targets, or provider capability lists, call list_active_endpoints or list_endpoint_tool_targets first. Prefer the returned tool_target_lines and executable_tools_by_endpoint fields, then compact_endpoints for display details; report endpoint_id, provider_type, status, workspace_ids, and executable_tools without silently dropping entries, including Raspberry Pi rpi.device.* / rpi.button.read tools when present. +- For Raspberry Pi device capability calls, use send_endpoint_message with delivery_kind=tool_call, target_id from the endpoint inventory, tool_key such as rpi.device.list or rpi.device.set, arguments required by that tool, and confirmed=true only after explicit confirmation for capabilities that require it. Boundaries: - Stay in general mode unless the next immediate step clearly requires automation coordination or Danxi forum tools. diff --git a/prompt/modes/general b/prompt/modes/general index 18fe1dc..2ba6a59 100644 --- a/prompt/modes/general +++ b/prompt/modes/general @@ -11,7 +11,8 @@ Priorities: Behavior: - Answer directly when no tool is needed. - For ordinary web lookup, use the high-level web tools and keep the answer practical. -- When the user asks to list endpoints, endpoint tools, workspace devices, online devices, executable targets, or provider capabilities, call list_active_endpoints or list_endpoint_tool_targets first. Do not answer from memory, assumptions, or the current chat endpoint. Prefer the returned tool_target_lines and executable_tools_by_endpoint fields for the final answer; then use compact_endpoints for display details. Report endpoint_id, provider_type, status, workspace_ids, and executable_tools compactly but completely. +- When the user asks to list endpoints, endpoint tools, workspace devices, online devices, executable targets, or provider capabilities, call list_active_endpoints or list_endpoint_tool_targets first. Do not answer from memory, assumptions, or the current chat endpoint. Prefer the returned tool_target_lines and executable_tools_by_endpoint fields for the final answer; then use compact_endpoints for display details. Report endpoint_id, provider_type, status, workspace_ids, and executable_tools compactly but completely, including Raspberry Pi rpi.device.* / rpi.button.read tools when present. +- To call a Raspberry Pi device capability, use send_endpoint_message with delivery_kind=tool_call, target_id set to the returned endpoint_id, tool_key such as rpi.device.list or rpi.device.set, arguments as required by that tool, and confirmed=true only after explicit confirmation for capabilities that require it. - When the user is managing TODOs, task_recognition can expose manage_tasks. - When the user is asking for a time-triggered reminder or recurring assistant action, task_recognition can expose create_scheduled_workflow and manage_scheduled_workflows. Use create_scheduled_delivery/manage_scheduled_deliveries only when the scheduled output must be delivered to an endpoint address; reserve manage_scheduled_jobs for advanced Scheduler maintenance. - Switch modes only when the next step clearly needs automation coordination or Danxi forum tools. diff --git a/scripts/rpi/diagnose-device-capabilities.sh b/scripts/rpi/diagnose-device-capabilities.sh new file mode 100644 index 0000000..ae40219 --- /dev/null +++ b/scripts/rpi/diagnose-device-capabilities.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_DIR="${REPO_DIR:-/opt/meetyou/MeetYou}" +PYTHON_BIN="${PYTHON_BIN:-${REPO_DIR}/.venv-rpi/bin/python}" +CONFIG_PATH="${1:-${CONFIG_PATH:-/etc/meetyou/rpi-endpoint.json}}" +ENV_FILE="${ENV_FILE:-/etc/meetyou/rpi-endpoint.env}" +SERVICE_NAME="${SERVICE_NAME:-meetyou-rpi-endpoint.service}" + +if [[ ! -x "${PYTHON_BIN}" ]]; then + PYTHON_BIN="python3" +fi + +echo "== Raspberry Pi device capability diagnostics ==" +date -Is +echo + +echo "== Repository ==" +echo "REPO_DIR=${REPO_DIR}" +if [[ -d "${REPO_DIR}/.git" ]]; then + git -C "${REPO_DIR}" branch --show-current || true + git -C "${REPO_DIR}" rev-parse --short HEAD || true + git -C "${REPO_DIR}" log -1 --oneline || true +else + echo "No git checkout found at ${REPO_DIR}" +fi +echo + +echo "== Service ==" +systemctl show "${SERVICE_NAME}" -p ActiveState -p SubState -p ExecMainStartTimestamp -p FragmentPath --no-pager || true +systemctl cat "${SERVICE_NAME}" --no-pager | grep -E 'ExecStart=|WorkingDirectory=|EnvironmentFile=|User=|Group=|SupplementaryGroups=' || true +echo + +echo "== Python registry ==" +export PYTHONPATH="${REPO_DIR}${PYTHONPATH:+:${PYTHONPATH}}" +export MEETYOU_RPI_DIAGNOSTIC_ENV_FILE="${ENV_FILE}" +"${PYTHON_BIN}" - "${CONFIG_PATH}" <<'PY' +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +config_path = sys.argv[1] +env_file = os.environ.get("MEETYOU_RPI_DIAGNOSTIC_ENV_FILE", "") + +if env_file and Path(env_file).exists(): + try: + from dotenv import load_dotenv + load_dotenv(env_file, override=False) + except Exception: + pass + +try: + import meetyou_rpi_endpoint + from meetyou_rpi_endpoint.config import load_rpi_endpoint_config + from meetyou_rpi_endpoint.registry import build_default_registry +except Exception as exc: + print(f"import_failed: {type(exc).__name__}: {exc}") + raise SystemExit(1) + +print(f"package_path={Path(meetyou_rpi_endpoint.__file__).resolve()}") +config = load_rpi_endpoint_config(config_path) +registry = build_default_registry(config, force_fake_gpio=True) +names = registry.names() +print(f"endpoint_id={config.executor_endpoint_id}") +print(f"device_count={len(config.devices)}") +print(f"capability_count={len(names)}") +print("capabilities=" + json.dumps(names, ensure_ascii=False)) +print( + "device_capability_confirmation=" + + json.dumps( + { + item["tool_key"]: item["requires_confirmation"] + for item in registry.tool_definitions() + if item["tool_key"].startswith("rpi.device") or item["tool_key"].startswith("rpi.button") + }, + ensure_ascii=False, + sort_keys=True, + ) +) +print("devices=" + json.dumps([device.device_id for device in config.devices], ensure_ascii=False)) +PY +echo + +echo "== Recent capability logs ==" +journalctl -u "${SERVICE_NAME}" --since "30 minutes ago" --no-pager | grep -E 'advertising|registered_capability_count|rpi.device|rpi.button' || true diff --git a/scripts/rpi/install-systemd.sh b/scripts/rpi/install-systemd.sh index d080120..10b8627 100644 --- a/scripts/rpi/install-systemd.sh +++ b/scripts/rpi/install-systemd.sh @@ -51,6 +51,14 @@ ENV echo "Created ${CONFIG_DIR}/rpi-endpoint.env without secrets. Add MEETYOU_RPI_ENDPOINT_TOKEN manually." else echo "Keeping existing ${CONFIG_DIR}/rpi-endpoint.env" + if ! grep -Eq '^[[:space:]]*(export[[:space:]]+)?MEETYOU_RPI_GPIO_PIN_FACTORY=' "${CONFIG_DIR}/rpi-endpoint.env"; then + { + echo + echo "# Raspberry Pi 5 GPIO should use lgpio instead of legacy RPi.GPIO/native backends:" + echo "MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio" + } >>"${CONFIG_DIR}/rpi-endpoint.env" + echo "Added MEETYOU_RPI_GPIO_PIN_FACTORY=lgpio to existing ${CONFIG_DIR}/rpi-endpoint.env" + fi fi if [[ ! -d "${VENV_DIR}" ]]; then diff --git a/scripts/rpi/smoke-test.sh b/scripts/rpi/smoke-test.sh index b47f7bc..5a5dc59 100644 --- a/scripts/rpi/smoke-test.sh +++ b/scripts/rpi/smoke-test.sh @@ -3,11 +3,23 @@ set -euo pipefail REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" PYTHON_BIN="${PYTHON_BIN:-python3}" -CONFIG_PATH="${1:-${REPO_DIR}/user/rpi_endpoint.example.json}" +DEFAULT_CONFIG_PATH="/etc/meetyou/rpi-endpoint.json" +if [[ ! -f "${DEFAULT_CONFIG_PATH}" ]]; then + DEFAULT_CONFIG_PATH="${REPO_DIR}/user/rpi_endpoint.example.json" +fi +CONFIG_PATH="${1:-${CONFIG_PATH:-${DEFAULT_CONFIG_PATH}}}" +ENV_FILE="${ENV_FILE:-/etc/meetyou/rpi-endpoint.env}" cd "${REPO_DIR}" +export PYTHONPATH="${REPO_DIR}${PYTHONPATH:+:${PYTHONPATH}}" +HEALTH_ARGS=(--config "${CONFIG_PATH}") +if [[ -f "${ENV_FILE}" ]]; then + HEALTH_ARGS+=(--env-file "${ENV_FILE}") +fi + +"${PYTHON_BIN}" -m endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.health "${HEALTH_ARGS[@]}" + "${PYTHON_BIN}" -m endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.main \ --config "${CONFIG_PATH}" \ --simulate \ --fake-gpio - diff --git a/tests/test_endpoint_tools.py b/tests/test_endpoint_tools.py index aea59b9..567a811 100644 --- a/tests/test_endpoint_tools.py +++ b/tests/test_endpoint_tools.py @@ -5,9 +5,20 @@ from types import SimpleNamespace from core.runtime_context import bind_event_context, reset_event_context +from core.services.tool_router_service import ToolRouterError, ToolRouterService from tools.endpoint_tools import EndpointTools +_RPI_DEVICE_TOOLS = [ + "rpi.device.list", + "rpi.device.status", + "rpi.device.set", + "rpi.device.pulse", + "rpi.device.blink", + "rpi.button.read", +] + + class _FakeEndpointWsManager: def __init__(self): self.notices: list[dict] = [] @@ -32,6 +43,17 @@ async def snapshot(self, **filters): async def connected_endpoint_ids(self): return set(self._connected_ids) + def connect_endpoint(self, *, endpoint_id: str, provider_type: str, display_name: str): + self._connected_ids.add(endpoint_id) + self._snapshots.append( + { + "endpoint_id": endpoint_id, + "provider": {"provider_type": provider_type, "display_name": display_name}, + "connected_at": "2026-04-26T00:00:02Z", + "updated_at": "2026-04-26T00:00:03Z", + } + ) + async def publish_notice(self, *, target_endpoint_id: str, payload: dict) -> int: self.notices.append({"target_endpoint_id": target_endpoint_id, "payload": dict(payload)}) return 1 @@ -75,8 +97,8 @@ def add_raspberry_endpoint_with_membership_only_scope(self): endpoint = SimpleNamespace( id="endpoint-row-rpi", endpoint_id="raspberry.pi.executor", - endpoint_type="edge_executor", - provider_type="raspberry_pi", + endpoint_type="rpi_executor", + provider_type="rpi", transport_type="websocket", status="registered", workspace_scope=["legacy-lab"], @@ -86,12 +108,15 @@ def add_raspberry_endpoint_with_membership_only_scope(self): self.endpoints.append(endpoint) self.capabilities_by_endpoint[endpoint.id] = [ SimpleNamespace( - capability_id="endpoint.raspberry.pi.executor.sensor.read", - tool_key="sensor.read", - risk_level="read", - requires_confirmation=False, + capability_id=f"endpoint.raspberry.pi.executor.{tool_key}", + tool_key=tool_key, + risk_level="local_write" + if tool_key in {"rpi.device.set", "rpi.device.pulse", "rpi.device.blink"} + else "read", + requires_confirmation=tool_key in {"rpi.device.set", "rpi.device.pulse", "rpi.device.blink"}, enabled=True, ) + for tool_key in _RPI_DEVICE_TOOLS ] self.memberships_by_endpoint[endpoint.id] = [SimpleNamespace(workspace_id=self.workspace.id)] return endpoint @@ -106,6 +131,62 @@ async def dispatch_tool_call(self, **kwargs): return {"summary": "read ok"} +class _RouterWorkspaceService: + def get_by_workspace_id(self, workspace_id): + return SimpleNamespace(id="workspace-row-personal", workspace_id=workspace_id) + + def get_by_id(self, row_id): + return SimpleNamespace(id=row_id, workspace_id="personal") if row_id else None + + +class _RouterEndpointService: + def __init__(self): + self.endpoint = SimpleNamespace( + id="endpoint-row-rpi", + endpoint_id="raspberry.pi.executor", + provider_type="rpi", + status="online", + workspace_scope=["personal"], + meta={}, + ) + + def get_by_endpoint_id(self, endpoint_id): + return self.endpoint if endpoint_id == self.endpoint.endpoint_id else None + + def get_by_id(self, row_id): + return self.endpoint if row_id == self.endpoint.id else None + + +class _RouterCapabilityService: + def __init__(self, endpoint_service: _RouterEndpointService): + self.capability = SimpleNamespace( + id="capability-row-rpi-set", + endpoint_id=endpoint_service.endpoint.id, + capability_id="endpoint.raspberry.pi.executor.rpi.device.set", + tool_key="rpi.device.set", + enabled=True, + requires_confirmation=True, + risk_level="local_write", + meta={}, + ) + + def list_for_endpoint(self, *, endpoint_row_id): + return [self.capability] if endpoint_row_id == self.capability.endpoint_id else [] + + def list_enabled_for_tool(self, tool_key): + return [self.capability] if tool_key == self.capability.tool_key else [] + + +class _NoopRouterService: + def get_by_session_id(self, session_id): + del session_id + return None + + def get_by_id(self, row_id): + del row_id + return None + + class EndpointToolsTests(unittest.IsolatedAsyncioTestCase): def _tools(self): endpoint_service = _FakeEndpointService() @@ -200,6 +281,88 @@ async def test_endpoint_tool_call_routes_through_tool_router(self): self.assertEqual(router.calls[0]["arguments"], {"path": "demo.txt"}) self.assertTrue(router.calls[0]["confirmed"]) + async def test_rpi_device_tool_calls_route_through_tool_router_with_confirmed_flag(self): + tools, router, manager = self._tools() + endpoint_service = tools._core_domain.services.endpoint + raspberry = endpoint_service.add_raspberry_endpoint_with_membership_only_scope() + manager.connect_endpoint( + endpoint_id=raspberry.endpoint_id, + provider_type=raspberry.provider_type, + display_name="Raspberry Pi", + ) + + list_result = await tools.send_endpoint_message( + target_type="endpoint", + target_id="raspberry.pi.executor", + delivery_kind="tool_call", + tool_key="rpi.device.list", + arguments={}, + workspace_id="personal", + ) + set_result = await tools.send_endpoint_message( + target_type="endpoint", + target_id="raspberry.pi.executor", + delivery_kind="tool_call", + tool_key="rpi.device.set", + arguments={"device_id": "desk_led", "value": True}, + workspace_id="personal", + confirmed=True, + ) + + self.assertTrue(list_result["ok"]) + self.assertTrue(set_result["ok"]) + self.assertEqual(router.calls[0]["target_endpoint_id"], "raspberry.pi.executor") + self.assertEqual(router.calls[0]["tool_key"], "rpi.device.list") + self.assertEqual(router.calls[0]["arguments"], {}) + self.assertFalse(router.calls[0]["confirmed"]) + self.assertEqual(router.calls[1]["target_endpoint_id"], "raspberry.pi.executor") + self.assertEqual(router.calls[1]["tool_key"], "rpi.device.set") + self.assertEqual(router.calls[1]["arguments"], {"device_id": "desk_led", "value": True}) + self.assertTrue(router.calls[1]["confirmed"]) + + async def test_rpi_device_write_without_confirmation_keeps_toolrouter_rejection(self): + endpoint_service = _RouterEndpointService() + workspace_service = _RouterWorkspaceService() + capability_service = _RouterCapabilityService(endpoint_service) + router = ToolRouterService( + actor_service=_NoopRouterService(), + workspace_service=workspace_service, + endpoint_service=endpoint_service, + endpoint_capability_service=capability_service, + session_service=_NoopRouterService(), + thread_service=_NoopRouterService(), + operation_service=_NoopRouterService(), + operation_call_service=_NoopRouterService(), + ) + router.set_connected_endpoint_ids_getter(lambda: {"raspberry.pi.executor"}) + tools = EndpointTools() + tools.set_core_domain( + SimpleNamespace( + tool_router=router, + services=SimpleNamespace( + endpoint=endpoint_service, + endpoint_capability=capability_service, + workspace=workspace_service, + session=_NoopRouterService(), + ), + ) + ) + + with self.assertRaises(ToolRouterError) as raised: + await tools.send_endpoint_message( + target_type="endpoint", + target_id="raspberry.pi.executor", + delivery_kind="tool_call", + tool_key="rpi.device.set", + arguments={"device_id": "relay_1", "value": True}, + workspace_id="personal", + confirmed=False, + ) + + self.assertEqual(raised.exception.code, "tool_confirmation_required") + self.assertEqual(raised.exception.details["endpoint_id"], "raspberry.pi.executor") + self.assertEqual(raised.exception.details["tool_key"], "rpi.device.set") + async def test_list_active_endpoints_reports_workspace_ids_without_unpack_error(self): tools, _, _ = self._tools() @@ -224,35 +387,49 @@ async def test_list_endpoint_tools_uses_workspace_membership_like_operator_topol tools, _, manager = self._tools() endpoint_service = tools._core_domain.services.endpoint raspberry = endpoint_service.add_raspberry_endpoint_with_membership_only_scope() - manager._connected_ids.add(raspberry.endpoint_id) + manager.connect_endpoint( + endpoint_id=raspberry.endpoint_id, + provider_type=raspberry.provider_type, + display_name="Raspberry Pi", + ) payload = await tools.list_endpoint_tool_targets(workspace_id="personal", include_tools=True) + filtered_payload = await tools.list_endpoint_tool_targets(workspace_id="personal", tool_key="rpi.device.list") self.assertTrue(payload["ok"]) endpoint_ids = {item["endpoint_id"] for item in payload["endpoints"]} self.assertIn("desktop.main.executor", endpoint_ids) self.assertIn("raspberry.pi.executor", endpoint_ids) + self.assertEqual(filtered_payload["endpoint_ids"], ["raspberry.pi.executor"]) + self.assertEqual(filtered_payload["endpoints"][0]["matched_tool_key"], "rpi.device.list") self.assertIn("raspberry.pi.executor", payload["endpoint_ids"]) self.assertIn( - "raspberry.pi.executor | provider=raspberry_pi | status=online | tools=sensor.read", + ( + "raspberry.pi.executor | provider=rpi | status=online | " + "tools=rpi.device.list, rpi.device.status, rpi.device.set, " + "rpi.device.pulse, rpi.device.blink, rpi.button.read" + ), payload["tool_target_lines"], ) - self.assertEqual(payload["executable_tools_by_endpoint"]["raspberry.pi.executor"], ["sensor.read"]) + self.assertEqual(payload["executable_tools_by_endpoint"]["raspberry.pi.executor"], _RPI_DEVICE_TOOLS) rendered = json.dumps(payload, ensure_ascii=False) self.assertLess(rendered.index("tool_target_lines"), rendered.index("compact_endpoints")) - self.assertLess(rendered.index("raspberry.pi.executor | provider=raspberry_pi"), 512) + self.assertLess(rendered.index("raspberry.pi.executor | provider=rpi"), 512) compact_raspberry = next(item for item in payload["compact_endpoints"] if item["endpoint_id"] == "raspberry.pi.executor") - self.assertEqual(compact_raspberry["executable_tools"], ["sensor.read"]) + self.assertEqual(compact_raspberry["executable_tools"], _RPI_DEVICE_TOOLS) raspberry_payload = next(item for item in payload["endpoints"] if item["endpoint_id"] == "raspberry.pi.executor") self.assertEqual(raspberry_payload["workspace_ids"], ["personal", "legacy-lab"]) self.assertEqual(raspberry_payload["status"], "online") - self.assertEqual(raspberry_payload["tool_keys"], ["sensor.read"]) - self.assertEqual(raspberry_payload["capability_count"], 1) + self.assertEqual(raspberry_payload["tool_keys"], _RPI_DEVICE_TOOLS) + self.assertEqual(raspberry_payload["capability_count"], len(_RPI_DEVICE_TOOLS)) active_payload = await tools.list_active_endpoints(workspace_id="personal", include_tools=True) self.assertIn("raspberry.pi.executor", active_payload["endpoint_ids"]) active_endpoint_ids = {item["endpoint_id"] for item in active_payload["endpoints"]} self.assertIn("raspberry.pi.executor", active_endpoint_ids) + self.assertEqual(active_payload["executable_tools_by_endpoint"]["raspberry.pi.executor"], _RPI_DEVICE_TOOLS) + active_compact_raspberry = next(item for item in active_payload["compact_endpoints"] if item["endpoint_id"] == "raspberry.pi.executor") + self.assertEqual(active_compact_raspberry["executable_tools"], _RPI_DEVICE_TOOLS) async def test_endpoint_inventory_defaults_to_complete_compact_tool_lists(self): tools, _, _ = self._tools() diff --git a/tests/test_rpi_core_integration.py b/tests/test_rpi_core_integration.py index 9fef853..505d40a 100644 --- a/tests/test_rpi_core_integration.py +++ b/tests/test_rpi_core_integration.py @@ -18,7 +18,7 @@ from core.services.operation_call_service import OperationCallService from core.services.operation_service import OperationService from core.services.tool_router_service import ToolRouterError, ToolRouterService -from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.config import RpiEndpointConfig, SecurityConfig +from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.config import DeviceConfig, RpiEndpointConfig, SecurityConfig from endpoint_providers.raspberry_pi.meetyou_rpi_endpoint.protocol import ( build_call_accepted, build_call_error, @@ -150,6 +150,18 @@ async def asyncSetUp(self): gpio_allowed_pins=[17], gpio_write_default_duration_ms=0, ), + devices=[ + DeviceConfig( + device_id="desk_led", + type="led", + name="Desk LED", + pin=17, + direction="out", + active_high=True, + max_on_ms=1000, + requires_confirmation=False, + ) + ], ) self.rpi_registry = build_default_registry(self.config, force_fake_gpio=True) self.rpi_runner = OperationRunner(self.rpi_registry, default_timeout_seconds=1, max_timeout_seconds=1) @@ -281,6 +293,8 @@ async def test_pi_capability_registration_is_received_by_core(self): self.assertIn("rpi.echo", capability_keys) self.assertIn("rpi.system.info", capability_keys) self.assertIn("rpi.gpio.read", capability_keys) + self.assertIn("rpi.device.list", capability_keys) + self.assertIn("rpi.device.set", capability_keys) self.assertNotIn("rpi.shell.safe_exec", capability_keys) async def test_tool_router_resolves_rpi_capability_to_endpoint_target(self): @@ -316,6 +330,22 @@ async def test_rpi_echo_operation_reaches_endpoint_and_records_completed_result( self.assertIn("running", update_phases) self.assertIn("completed", update_phases) + async def test_rpi_device_list_operation_reaches_endpoint_and_records_completed_result(self): + result, monitor = await self._dispatch_with_fake_pi( + tool_key="rpi.device.list", + arguments={}, + return_operation=True, + ) + + self.assertEqual(result["status"], "succeeded") + self.assertEqual(result["result"]["devices"][0]["device_id"], "desk_led") + operation = self.harness.operation.get_by_operation_id(result["operation_id"]) + call = self.harness.operation_call.get_by_call_id(result["call_id"]) + self.assertEqual(operation.status, "succeeded") + self.assertEqual(call.status, "succeeded") + update_phases = await self._wait_for_operation_update_phase(monitor, "completed") + self.assertIn("completed", update_phases) + async def test_rpi_gpio_failure_records_failed_operation(self): result, monitor = await self._dispatch_with_fake_pi( tool_key="rpi.gpio.read", diff --git a/user/rpi_endpoint.example.json b/user/rpi_endpoint.example.json index 64f35a2..459e7f6 100644 --- a/user/rpi_endpoint.example.json +++ b/user/rpi_endpoint.example.json @@ -24,6 +24,36 @@ "safe_shell_allowlist": [], "gpio_allowed_pins": [17, 27, 22], "gpio_write_default_duration_ms": 500 - } + }, + "devices": [ + { + "device_id": "desk_led", + "type": "led", + "name": "Desk LED", + "pin": 17, + "direction": "out", + "active_high": true, + "max_on_ms": 5000, + "requires_confirmation": false + }, + { + "device_id": "relay_1", + "type": "relay", + "name": "Relay 1", + "pin": 27, + "direction": "out", + "active_high": true, + "max_on_ms": 3000, + "requires_confirmation": true + }, + { + "device_id": "button_1", + "type": "button", + "name": "Button 1", + "pin": 22, + "direction": "in", + "active_high": false, + "pull": "up" + } + ] } - diff --git a/user/tools.example.json b/user/tools.example.json index 84836f6..453a92d 100644 --- a/user/tools.example.json +++ b/user/tools.example.json @@ -217,7 +217,7 @@ "type": "function", "function": { "name": "list_endpoint_tool_targets", - "description": "List online Endpoint execution targets. Use tool_target_lines, endpoint_ids, and executable_tools_by_endpoint first; request capability details only for a specific follow-up.", + "description": "List online Endpoint execution targets, including Raspberry Pi device tools such as rpi.device.list/status/set/pulse/blink and rpi.button.read. Use tool_target_lines, endpoint_ids, and executable_tools_by_endpoint first; request capability details only for a specific follow-up.", "parameters": { "type": "object", "properties": { @@ -227,7 +227,7 @@ }, "tool_key": { "type": "string", - "description": "Optional endpoint capability tool key, such as file.read or shell.exec." + "description": "Optional endpoint capability tool key, such as file.read, shell.exec, or rpi.device.list." }, "include_tools": { "type": "boolean", @@ -289,7 +289,7 @@ "type": "function", "function": { "name": "send_endpoint_message", - "description": "Send a realtime notice or dispatch an Endpoint tool call to a target Endpoint other than the normal current-session reply path. Do not use this to answer the originating user or to send progress updates to the same endpoint; use emit_progress_notice for progress and the final assistant answer for the actual reply. Use only when the user explicitly asks to notify or call a specific target Endpoint.", + "description": "Send a realtime notice or dispatch an Endpoint tool call to a target Endpoint other than the normal current-session reply path. Do not use this to answer the originating user or to send progress updates to the same endpoint; use emit_progress_notice for progress and the final assistant answer for the actual reply. Use only when the user explicitly asks to notify or call a specific target Endpoint. Example Raspberry Pi device calls: target_id=, delivery_kind=tool_call, tool_key=rpi.device.list, arguments={}; or tool_key=rpi.device.set, arguments={\"device_id\":\"desk_led\",\"value\":true}, confirmed=true after explicit confirmation when required.", "parameters": { "type": "object", "properties": {