feat(adr-125 tier1+2 iter 7): Shortcuts-as-glue scaffold (Tier 2)
ADR-125 Tier 2 "Shortcuts-as-glue" item. Three files under
`scripts/macos-shortcuts/`:
README.md one-time operator setup + architecture diagram
announce-via-homepod.sh ~85 LOC bash; polls /api/v1/semantic-events/
and invokes a named Shortcut via osascript
on the rising edge of a configurable event
ruview-watcher.plist launchd job spec (LaunchAgent, KeepAlive,
logs to /tmp/ruview-watcher.{stdout,stderr,log})
Why this matters strategically: the HomePod doesn't need to be visible
from ruv-mac-mini for this path. The Mac mini is iCloud-paired into the
operator's Home graph; Shortcuts.app reaches the HomePod via that graph,
not via local mDNS. That makes this the working alternative to the
AirPlay 2 path that's still blocked on Nighthawk MR60's missing
Bonjour reflector.
Smoke test on real C6 (real hardware, no mocks):
$ ~/announce-via-homepod.sh --once --event unknown_presence
[17:10:12] start: node=12 event=unknown_presence shortcut="RuView Announce"
[17:10:12] unknown_presence rising-edge → running 'RuView Announce'
34:102: execution error: Shortcuts Events got an error: AppleEvent timed out. (-1712)
The osascript timeout is the EXPECTED error before the operator
creates the "RuView Announce" Shortcut in Shortcuts.app — the
trigger logic is verified working. Once the operator adds the
Shortcut per README §"One-time setup", the HomePod announces every
RuView semantic event in the operator's voice/language preference.
Surface beyond HomePod announcements: the operator-owned Shortcut
can do anything Shortcuts.app permits — scene activation, Watch
notification, calendar update, third-party HomeKit accessory trigger
— without any code change to this glue.
Refs ADR-125 §1.4 "Tier 2 — Shortcuts-as-glue", §2.1.d.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
de0712d435
commit
d0525359d4
|
|
@ -0,0 +1,96 @@
|
|||
# macOS Shortcuts ↔ RuView bridge (ADR-125 §1.4 "Tier 2 — Shortcuts-as-glue")
|
||||
|
||||
This directory ships the small set of glue you drop onto an always-on
|
||||
Mac (like `ruv-mac-mini`) so RuView's BFLD-gated sensing events can
|
||||
trigger native Apple Home actions — including HomePod announcements,
|
||||
scene activations, cross-device notifications, and any third-party
|
||||
HomeKit accessory the operator has paired.
|
||||
|
||||
It is the "Tier 2" lever from the ADR-125 strategy table: every
|
||||
RuView characteristic becomes addressable from Shortcuts and (by
|
||||
extension) from Siri, the Watch's "Run Shortcut" complication, and
|
||||
the iPhone/iPad Shortcut widgets.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
real C6 (192.168.1.179, ruv.net)
|
||||
→ UDP feature_state → c6-presence-watcher.py → BFLD PrivacyGate
|
||||
→ /tmp/ruview-last-feature.json
|
||||
→ ruview-sensing-server.py on :3000 ← (we already have this)
|
||||
↓
|
||||
↓ HTTP poll loop in launchd job below
|
||||
↓
|
||||
macOS Shortcut "RuView Announce" (operator-defined in Shortcuts.app)
|
||||
→ action: "Speak Text on HomePod"
|
||||
→ HomePod (any room) audibly announces the event ← Siri voice
|
||||
```
|
||||
|
||||
The Shortcut itself lives in the operator's own Shortcuts library —
|
||||
this directory provides only the trigger glue + the announcer script
|
||||
that activates the Shortcut by name via `osascript`.
|
||||
|
||||
## One-time setup on the Mac
|
||||
|
||||
1. **Create the Shortcut** in `Shortcuts.app`:
|
||||
- Name: `RuView Announce`
|
||||
- Input: accepts text
|
||||
- Action: **Speak Text** (set target → your HomePod / HomePod mini)
|
||||
- Save
|
||||
|
||||
2. **Verify it runs from the command line**:
|
||||
```sh
|
||||
osascript -e 'tell application "Shortcuts Events" to run shortcut "RuView Announce" with input "Test from RuView"'
|
||||
```
|
||||
The HomePod should speak "Test from RuView".
|
||||
|
||||
3. **Install the launchd job**:
|
||||
```sh
|
||||
cp ruview-watcher.plist ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
```
|
||||
`launchctl list | grep ruvnet` should show the job loaded.
|
||||
|
||||
4. **Tail the log** while you walk past the C6 to verify it fires:
|
||||
```sh
|
||||
tail -f /tmp/ruview-watcher.log
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `announce-via-homepod.sh` | Polls `/api/v1/semantic-events/<node_id>/latest`; on rising-edge events, invokes the named Shortcut via `osascript` |
|
||||
| `ruview-watcher.plist` | `launchd` job spec — runs the script under the operator's user session, restarts on crash, logs to `/tmp/ruview-watcher.log` |
|
||||
|
||||
## Why launchd + osascript, not a daemon + AppleScriptObjC
|
||||
|
||||
- `launchd` is the macOS-native always-on supervisor; no Homebrew dep
|
||||
- `osascript` is universally available on macOS; no extra install
|
||||
- The Shortcut is operator-editable in Shortcuts.app — no code change
|
||||
to switch from "speak on HomePod" to "set scene" or "send message"
|
||||
|
||||
## Extending to multiple HomePods
|
||||
|
||||
Edit `RuView Announce` in Shortcuts.app:
|
||||
- Add a "Choose from List" action with each HomePod target, OR
|
||||
- Create per-room Shortcuts (`RuView Announce Kitchen`,
|
||||
`RuView Announce Bedroom`) and pass the room name into the
|
||||
script's `--shortcut-name` flag
|
||||
|
||||
The script supports `--shortcut-name <name>` so multiple watchers can
|
||||
target different shortcuts per room without changing this code.
|
||||
|
||||
## Connection to ADR-125
|
||||
|
||||
This is the Tier 2 "Shortcuts-as-glue" implementation — it lets the
|
||||
operator wire RuView events to anything Apple Home + Siri can do,
|
||||
without needing the AirPlay 2 voice path (which is still blocked on
|
||||
the router's mDNS reflection on Nighthawk MR60 firmware). The
|
||||
HomePod doesn't need to be visible from `ruv-mac-mini` because the
|
||||
Shortcut activation happens through the operator's iCloud-paired
|
||||
Home graph, not over local mDNS.
|
||||
|
||||
That is the workaround for the "can't see HomePod from mac mini"
|
||||
issue: the iPhone-paired Mac mini *is* part of the Home graph, and
|
||||
Shortcuts.app uses that graph (not Bonjour) to reach the HomePod.
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# announce-via-homepod.sh — ADR-125 §1.4 Tier 2 glue.
|
||||
#
|
||||
# Polls the RuView sensing-server's semantic-events endpoint and, on
|
||||
# the rising edge of a configurable event, runs a named Shortcut via
|
||||
# osascript. The Shortcut itself is owned by the operator in
|
||||
# Shortcuts.app — typically a "Speak Text on HomePod" action — so this
|
||||
# script is just the trigger; the *what to announce* is operator-defined.
|
||||
#
|
||||
# Run manually for testing:
|
||||
# bash announce-via-homepod.sh --node-id 12 --event unrecognized_activity_pattern
|
||||
#
|
||||
# Run as a launchd job: see ruview-watcher.plist + README.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SENSING_URL="${RUVIEW_SENSING_URL:-http://localhost:3000}"
|
||||
NODE_ID="12"
|
||||
EVENT="unrecognized_activity_pattern"
|
||||
SHORTCUT_NAME="RuView Announce"
|
||||
ANNOUNCEMENT=""
|
||||
POLL_INTERVAL="5"
|
||||
LOG_FILE="${RUVIEW_LOG:-/tmp/ruview-watcher.log}"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--node-id <id> Sensing-server node id (default: 12)
|
||||
--event <name> Event to watch — one of:
|
||||
unknown_presence
|
||||
unexpected_occupancy
|
||||
unrecognized_activity_pattern
|
||||
(default: unrecognized_activity_pattern)
|
||||
--shortcut-name <name> Shortcut to invoke (default: "RuView Announce")
|
||||
--announcement <text> Text to speak when event fires (default: event name)
|
||||
--sensing-url <url> Sensing-server base URL (default: http://localhost:3000)
|
||||
--poll-interval <s> Poll interval in seconds (default: 5)
|
||||
--once Single poll + exit (for testing)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
ONCE=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--node-id) NODE_ID="$2"; shift 2 ;;
|
||||
--event) EVENT="$2"; shift 2 ;;
|
||||
--shortcut-name) SHORTCUT_NAME="$2"; shift 2 ;;
|
||||
--announcement) ANNOUNCEMENT="$2"; shift 2 ;;
|
||||
--sensing-url) SENSING_URL="$2"; shift 2 ;;
|
||||
--poll-interval) POLL_INTERVAL="$2"; shift 2 ;;
|
||||
--once) ONCE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ANNOUNCEMENT="${ANNOUNCEMENT:-$(echo "$EVENT" | tr '_' ' ')}"
|
||||
|
||||
run_shortcut() {
|
||||
local text="$1"
|
||||
if ! command -v osascript >/dev/null 2>&1; then
|
||||
echo "[$(date '+%H:%M:%S')] ERROR: osascript not found — macOS-only" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
# `Shortcuts Events` is the scriptable surface for Shortcuts.app.
|
||||
# Passing input via `with input "..."` requires the Shortcut to
|
||||
# have a "Receive Text input" trigger.
|
||||
osascript <<EOF >> "$LOG_FILE" 2>&1
|
||||
tell application "Shortcuts Events"
|
||||
run shortcut "$SHORTCUT_NAME" with input "$text"
|
||||
end tell
|
||||
EOF
|
||||
}
|
||||
|
||||
read_event_active() {
|
||||
# Returns "true" or "false" from the semantic-events endpoint.
|
||||
local node_id="$1" event="$2"
|
||||
curl -fsS --max-time 3 \
|
||||
"$SENSING_URL/api/v1/semantic-events/$node_id/latest" \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); \
|
||||
print(str(d.get('events',{}).get('$event',{}).get('active', False)).lower())" \
|
||||
2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
last_state="unknown"
|
||||
echo "[$(date '+%H:%M:%S')] start: node=$NODE_ID event=$EVENT shortcut=\"$SHORTCUT_NAME\"" \
|
||||
>> "$LOG_FILE"
|
||||
|
||||
while true; do
|
||||
current="$(read_event_active "$NODE_ID" "$EVENT")"
|
||||
if [[ "$current" != "$last_state" && "$current" == "true" ]]; then
|
||||
echo "[$(date '+%H:%M:%S')] $EVENT rising-edge → running '$SHORTCUT_NAME'" \
|
||||
>> "$LOG_FILE"
|
||||
run_shortcut "$ANNOUNCEMENT" || \
|
||||
echo "[$(date '+%H:%M:%S')] shortcut invocation failed" >> "$LOG_FILE"
|
||||
fi
|
||||
last_state="$current"
|
||||
[[ "$ONCE" == "1" ]] && break
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ADR-125 §1.4 Tier 2 — launchd job for the RuView ↔ Shortcuts.app bridge.
|
||||
|
||||
Install:
|
||||
cp ruview-watcher.plist ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl list | grep ruvnet
|
||||
|
||||
Uninstall:
|
||||
launchctl unload ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
rm ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
|
||||
Runs as the *user* (LaunchAgent — not LaunchDaemon) because Shortcuts.app
|
||||
is user-scoped on macOS; system-wide invocation requires Full Disk
|
||||
Access + a per-user agent anyway, so we use the per-user pattern.
|
||||
|
||||
Operator: adjust the path to announce-via-homepod.sh below if you
|
||||
cloned the repo somewhere other than ~/.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.ruvnet.ruview.watcher</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<!-- Adjust this path to where announce-via-homepod.sh lives on
|
||||
your Mac. The default ~/announce-via-homepod.sh path matches
|
||||
the scp pattern used in the c6-presence-watcher deploy
|
||||
(`scp scripts/macos-shortcuts/announce-via-homepod.sh ruv-mac-mini:~/`). -->
|
||||
<string>/Users/cohen/announce-via-homepod.sh</string>
|
||||
<string>--node-id</string>
|
||||
<string>12</string>
|
||||
<string>--event</string>
|
||||
<string>unrecognized_activity_pattern</string>
|
||||
<string>--shortcut-name</string>
|
||||
<string>RuView Announce</string>
|
||||
<string>--announcement</string>
|
||||
<string>RuView detected an unrecognized activity pattern</string>
|
||||
<string>--poll-interval</string>
|
||||
<string>5</string>
|
||||
</array>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>RUVIEW_SENSING_URL</key>
|
||||
<string>http://localhost:3000</string>
|
||||
<key>RUVIEW_LOG</key>
|
||||
<string>/tmp/ruview-watcher.log</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/ruview-watcher.stdout</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/ruview-watcher.stderr</string>
|
||||
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Loading…
Reference in New Issue