Skill: nj070574-gif/paired (version 2.0.0)

--- name: paired description: Paired: Phone Agent. Bridges an OpenClaw agent to the user's own phone via Bluetooth and ADB. Provides SMS receive (MAP/MNS), SMS send (ADB), outgoing/incoming calls (HFP), contacts (PBAP), media control (AVRCP), file transfer (OBEX), PAN tethering, and v2.0.0+ voice cloning so the agent speaks in the user's own voice with word-level audio splicing and 30-language multilingual synthesis. Zero recurring cost, no Twilio, Telnyx, Vapi, ElevenLabs, or rented numbers. Voice cloning runs locally via VoxCPM2 (primary, 48kHz studio) with XTTS v2 fallback (24kHz), piper fallback (generic), and espeak-ng last resort. Triggers on phrases like "send SMS", "text someone", "call my phone", "make a call", "what's on my phone", "my contacts", "phone contacts", "play music", "pause", "next track", "send file to phone", "tether", "paired devices", "is my phone connected", "say it in my voice", "voice note", "speak as me", "clone my voice", "Bluetooth", "BT", "/sms", "/phone", "/voice", "/say", "ofono", "AVRCP", "MAP". Configuration lives in ~/.config/paired/paired.conf (phone MAC, adapter, trusted numbers list) and ~/.config/paired/voice.conf (voice cloning config, only if voice features are enabled). Always read the config before acting; never hardcode phone identifiers. capabilities: - sends-sms - places-phone-calls - reads-sms - reads-contacts - reads-clipboard - controls-mobile-device-via-adb - unlocks-mobile-device-with-stored-pin - bluetooth-pairing-agent - relays-to-external-channel-telegram - executes-sudo-commands - persistent-systemd-services - synthesises-cloned-voice - runs-local-docker-services requires: config: - path: ~/.config/paired/paired.conf purpose: phone MAC, adapter, trusted numbers list - path: ~/.config/paired/trusted-numbers.conf purpose: allowlist for high-impact outgoing actions (calls, SMS sends) - path: ~/.config/paired/pin purpose: phone unlock PIN (mode 0600 enforced) — OPTIONAL, only if --auto-unlock used sensitive: true - path: ~/.config/paired/gemini-keys.conf purpose: Gemini API key(s) for paired-respond — OPTIONAL, only if SMS LLM auto-reply is enabled (mode 0600 enforced) sensitive: true - path: ~/.config/paired/voice.conf purpose: voice cloning config (VoxCPM2/XTTS URLs, reference WAV path, word-clips dir) — OPTIONAL, only if voice cloning is used - path: ~/.config/paired/voice/reference.wav purpose: user-recorded voice reference for cloning — OPTIONAL, generated by paired-voice-setup.py sensitive: true - path: ~/.config/paired/voice/word-clips/ purpose: pre-recorded word clips for splicing — OPTIONAL - path: ~/.config/paired/inbox.key purpose: HMAC secret for paired-inbox-hook command dispatch (mode 0600 enforced) — generated by `paired-inbox-hook --keygen` sensitive: true system_packages: - bluez - ofono - android-tools-adb - systemd external_services: - telegram (optional, for command vocabulary and incoming-call/SMS alerts) python_packages: - dbus-python safety: scope: owner-operated network_access: bluetooth-LAN-only-plus-user-own-telegram credential_handling: user-supplied-only-no-hardcoded-fallbacks high_impact_actions: - all SMS sends require trusted-numbers allowlist OR explicit --confirm - all outbound calls require trusted-numbers allowlist OR explicit --confirm - phone unlock requires --auto-unlock flag explicitly per invocation - pairing-agent default mode is interactive (auto mode requires explicit --mode auto) notes: | This skill controls the user's real phone. It is intended for use on a Linux host that the user owns, paired to a phone the user owns, with Telegram bot credentials the user controls. It is not safe to expose any of these channels to untrusted parties. The trusted-numbers allowlist gates outgoing SMS/calls; keep it short and review it regularly. --- ## Execution context You are running on a Linux host with BlueZ + ofono installed and a phone paired over Bluetooth. The skill ships: - **Low-level primitives** at `skill/bin/bt-*.py` — BlueZ/ofono/ADB direct interfaces - **High-level wrappers** at `skill/wrappers/paired-*.py` — JSON-clean interfaces designed for agents to call - **Systemd unit files** at `skill/systemd/*.service.txt` — for persistent listeners (SMS push, call watch, command hook). The `.txt` suffix is a packaging convention; rename to `.service` when copying into `~/.config/systemd/user/` (see Installation below). ## Installation After `clawhub install paired`: ```bash # 1. Symlink (or copy) the bin/ and wrappers/ scripts into ~/bin/, dropping .py from filenames # so the user/agent can invoke `paired-sms-send` rather than `paired-sms-send.py`. mkdir -p ~/bin for f in ~/.openclaw/workspace/skills/paired/bin/*.py; do ln -sf "$f" ~/bin/"$(basename "$f" .py)" done for f in ~/.openclaw/workspace/skills/paired/wrappers/*.py; do ln -sf "$f" ~/bin/"$(basename "$f" .py)" done for f in ~/.openclaw/workspace/skills/paired/wrappers/*.sh; do ln -sf "$f" ~/bin/"$(basename "$f" .sh)" done chmod +x ~/.openclaw/workspace/skills/paired/bin/*.py \ ~/.openclaw/workspace/skills/paired/wrappers/*.py \ ~/.openclaw/workspace/skills/paired/wrappers/*.sh # 2. Optional: enable systemd user services. Strip the .txt suffix on copy. mkdir -p ~/.config/systemd/user for f in ~/.openclaw/workspace/skills/paired/systemd/*.service.txt; do cp "$f" ~/.config/systemd/user/"$(basename "$f" .txt)" done systemctl --user daemon-reload # 3. One-time inbox HMAC key generation (required for paired-inbox-hook) paired-inbox-hook --keygen # 4. Optional: enable the inbox hook (HMAC-signed command dispatcher) systemctl --user enable --now paired-inbox-hook.service ``` The `.py`, `.sh`, and `.service.txt` extensions exist to satisfy the ClawHub packaging text-file allowlist; on disk in your `~/bin/` and `~/.config/systemd/user/` they should be the unsuffixed names referenced throughout this document. When reasoning about a phone task, prefer the high-level `paired-*` wrappers — they handle trust checks, error formatting, and JSON output. Drop to `bt-*` only for diagnostic or low-level work. **The low-level `bt-call` and `bt-sms` primitives now also enforce the trusted-numbers allowlist** (since v1.0.4) and refuse to dial/SMS unlisted numbers unless `--confirm` is passed. **Acting on the world vs. answering questions:** for status queries ("is my phone connected?", "any new SMS?"), running the tool and reporting the result is the right call. For high-impact actions (sending SMS, dialling calls, pairing new devices, unlocking the phone), confirm with the user first unless the request is unambiguous and the destination is on the trusted-numbers allowlist. **Phone identity comes from `~/.config/paired/paired.conf`**, key `phone_bt_mac`. If a command needs the phone's MAC, read it from the config rather than asking the user. If the config is missing, tell the user to copy `paired.conf.example` and fill in the MAC. ## Most-used commands ### Stack health and discovery ```bash ~/bin/bt-test # 10-check stack health (one-shot diagnostic) ~/bin/bt-adapters # list HCI adapters ~/bin/bt-list --paired # paired devices with CONN/PAIR/TRUST status ~/bin/bt-list --connected # only currently-connected ~/bin/bt-list --scan 10 # 10-second scan for nearby ~/bin/bt-info <MAC> # full device detail (UUIDs, RSSI, profiles) ~/bin/bt-recover # USB-reset adapter if hung ``` ### Pairing and connection ```bash ~/bin/bt-pair <MAC> # initiate pairing (passkey via bt-agent) ~/bin/bt-pair <MAC> --connect # pair + trust + connect in one step ~/bin/bt-connect <MAC> # connect to an already-paired device ~/bin/bt-disconnect <MAC> ~/bin/bt-trust <MAC> | ~/bin/bt-untrust <MAC> ~/bin/bt-forget <MAC> # remove pairing entirely ``` ### Phone — SMS Receive (read-only via Bluetooth, fully working on most phones): ```bash ~/bin/paired-sms-watch --status # is the MNS push daemon running? ~/bin/paired-sms-watch --last 10 # last 10 SMS the daemon caught ~/bin/bt-sms-list --map <MAC> --max 10 # explicit MAP read of recent ~/bin/bt-adb-sms-list --limit 10 # ADB read of inbox (works while phone is locked) ~/bin/bt-adb-sms-list --sent --limit 10 # sent folder ``` Send (via ADB-over-USB autosend — Bluetooth MAP send is blocked on most Samsung firmware): ```bash ~/bin/paired-sms-send <NUMBER> "<text>" --json # Pass --auto-unlock to dismiss the lock screen using the PIN at # ~/.config/paired/pin (mode 0600 enforced). Pass --relock to re-lock after. # Without --auto-unlock, the tool returns error=keyguard_locked when phone is locked. ``` Telegram command shortcut: when the user types `/sms NUMBER text` in Telegram, run `~/bin/paired-sms-send NUMBER "text" --json` and report the JSON result. Quote the entire body as one argument. ### Phone — calls (HFP via ofono) ```bash ~/bin/paired-call status --json # active calls in structured form ~/bin/paired-call dial <NUMBER> # initiate outbound ~/bin/paired-call answer # accept incoming ~/bin/paired-call hangup # end all calls ~/bin/paired-call-and-speak <NUMBER> "<msg>" # dial + speak via Tasker TTS (see limits) ~/bin/bt-modems --full # ofono modem state, network registration ~/bin/paired-call-watch --last 10 # last 10 incoming calls caught by daemon ~/bin/paired-call-watch --status # is the call watcher daemon running? ``` Real-time incoming-call alerts run as a systemd user service (`paired-call-watch.service`) — caught calls go to the user's Telegram via `paired-call-watch-tg-hook` with sender + trust-status info. ### Phone — Telegram command vocabulary (deterministic, bypasses LLM) `paired-sms-command-hook.service` reads commands from a dedicated, append-only inbox at `~/.openclaw/paired/inbox/` (NOT from raw agent session logs — see Security model below) and dispatches recognised commands without invoking the LLM: | Telegram command | Action | Trust check | Underlying call | |---|---|---|---| | `/sms <num> <body>` | Send SMS via ADB | **trusted-numbers allowlist required** (or `--confirm`) | `paired-sms-send` | | `/phone <num>` | Dial outbound | **trusted-numbers allowlist required** (or `--confirm`) | `paired-call dial` | | `/phone <num> <msg>` | Dial + speak via Tasker TTS, optional SMS fallback | **trusted-numbers allowlist required** | `paired-call-and-speak` | | `/phone <num> attach <path>` | Dial + speak file content | **trusted-numbers allowlist required** | as above | | `/phone hangup` (or `/phone end`) | End all active calls | none | `paired-call hangup` | | `/phone status` | Active call state | none | `paired-call status` | Trusted list at `~/.config/paired/trusted-numbers.conf` — managed via `~/bin/paired-trusted add | remove | list`. UK number normalization: `+44`, `0044`, `44`, and `07` formats all match the same entry. **An empty trusted-numbers file blocks all outgoing SMS and calls except for explicit `--confirm` invocations.** This is the safe default — fill the file in deliberately. **SMS fallback for `/phone <num> <msg>`:** TTS during calls is blocked on some phone firmware (notably Samsung — see "Known phone-side limits" below). When TTS-during-call fails, the wrapper *can* also send an SMS with the same body so the recipient still gets the message. This is **opt-in per invocation** — pass `--with-sms-fallback` to enable it. Without that flag, a TTS failure returns an error and the wrapper does not send any SMS. The Telegram reply notes the chosen behaviour explicitly: "📞 TTS only" or "📞 TTS + 📨 SMS fallback (best-effort)". ### Security model (read this before enabling persistent services) This skill runs **persistent systemd services** that can dispatch phone actions automatically: - `paired-sms-watch.service` — listens for incoming SMS (via Bluetooth MAP-MNS), forwards alerts to Telegram. Read-only with respect to the phone. - `paired-call-watch.service` — listens for incoming calls (via ofono D-Bus), forwards alerts to Telegram. Read-only. - `paired-sms-command-hook.service` — reads command messages from `~/.openclaw/paired/inbox/`, dispatches recognised commands. **This is the surface that can act.** It accepts commands ONLY from a directory the user controls, with a per-message HMAC signature using a secret in `~/.config/paired/inbox.key` (mode 0600). Commands from any other source — raw session logs, the agent's chat memory, an SMS body, etc. — are NOT dispatched. **Why the inbox model:** earlier versions of this skill parsed the agent's session JSONL log directly. That made the session log a control surface — anything that landed in it (including unfiltered text from incoming SMS/calls) was a potential command source. The inbox model isolates the dispatch surface to messages the user (or a trusted bot relay) explicitly drops into the inbox dir, signed with the inbox key. **To stop all persistent services in one go:** ```bash systemctl --user stop paired-sms-watch paired-call-watch paired-sms-command-hook systemctl --user disable paired-sms-watch paired-call-watch paired-sms-command-hook ``` ### Phone — contacts (PBAP) ```bash ~/bin/bt-contacts <MAC> --max 10 # list 10 contacts ~/bin/bt-contacts <MAC> --pull # pull entire phonebook to ~/Downloads/bluetooth/<mac>.vcf ~/bin/bt-contacts <MAC> --search "name" # search by name ``` ### Phone — media (AVRCP via BT, fallback to ADB) ```bash ~/bin/paired-media status --json # current track + status (auto BT/ADB transport) ~/bin/paired-media play | pause | next | prev | stop ~/bin/paired-media volume 50 # set BT volume 0-100 ~/bin/paired-media current # what's playing right now ``` Auto-detects connected phone, picks BT/AVRCP first then falls back to ADB media controller. ### File transfer (OBEX) ```bash ~/bin/bt-send <FILE> <MAC> # push file to phone ~/bin/bt-receive # listen for incoming pushes (saves to ~/Downloads/bluetooth/) ~/bin/bt-browse <MAC> # OBEX-FTP browse (vendor-dependent) ``` ### Network (PAN) ```bash ~/bin/bt-pan up <MAC> # connect as NAP client (phone-side BT-tethering must be ON) ~/bin/bt-pan down # disconnect ~/bin/bt-pan status # show bnep0 state ``` ### GATT / BLE ```bash ~/bin/bt-gatt-tree <MAC> # enumerate services + characteristics ~/bin/bt-gatt-read <MAC> <UUID> # read a characteristic ~/bin/bt-gatt-write <MAC> <UUID> <HEX> # write a characteristic ``` ### Audio ```bash ~/bin/bt-audio <MAC> --info # available profiles ~/bin/bt-volume <MAC> # current volume ~/bin/bt-play <FILE> <MAC> # play file through BT speaker ``` ## LLM-drafted SMS reply (showcase feature, opt-in) When an SMS arrives whose body starts with the phrase set in `paired.conf[llm_trigger]` (default: `"Hi Agent,"`) **and** the sender is on the `paired.conf[llm_trigger_whitelist]`, `paired-respond` will: 1. Strip the trigger prefix 2. Call the configured LLM (Gemini / OpenAI / local) with a tight system prompt 3. Post a richer Telegram alert containing sender, original question, drafted reply, and a tap-to-copy `/sms` command The user decides whether to send the draft by tapping the `/sms` line. **No automatic SMS reply.** Empty whitelist disables the feature. Logs at `~/.paired/sms-respond.log`. ## Common phrasings → tool mapping - "Stack health?" → `~/bin/bt-test` - "What's paired?" / "What devices?" → `~/bin/bt-list --paired` - "Is my phone connected?" → `~/bin/bt-list --connected | grep -i <phone-label>` - "Pair with X" → `~/bin/bt-pair X --connect` - "Network signal?" → `~/bin/bt-modems --full` - "Any new SMS?" / "Watch SMS" → `~/bin/paired-sms-watch --last 5` - "Is SMS watcher running?" → `~/bin/paired-sms-watch --status` - `/sms NUMBER text` → `~/bin/paired-sms-send NUMBER "text" --json` - "Reply to that SMS with X" → user provides text; you call `paired-sms-send LAST_SENDER "X" --json`. Get LAST_SENDER from the most recent `~/.paired/sms-events.jsonl` entry. - "Call NUMBER" → `~/bin/paired-call dial NUMBER --json` - "Hang up" → `~/bin/paired-call hangup --json` - "Pause music" / "play music" / "next song" → `~/bin/paired-media pause/play/next` - "What's playing?" → `~/bin/paired-media current` ## Known phone-side limits (clean errors, not bugs) These are **phone-firmware constraints, not skill bugs**. The tools return clean errors and the docs explain workarounds. ### Samsung firmware (Note 8/9/10/20, S-series tested through OneUI 12) - **SMS-send via Bluetooth (HFP / MAP) is blocked.** Samsung firmware does not implement `MAP UpdateInbox` and ofono SMS-send returns access-denied. Workaround: use `paired-sms-send` (ADB-over-USB autosend) — fully working. - **In-call TTS is blocked at the audio policy level.** Samsung Telecom holds `AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE | AUDIOFOCUS_FLAG_LOCK` for the entire ring+call lifecycle. No third-party app (Tasker included) can inject audio into the call audio path. The `paired-call-and-speak` tool runs but the recipient hears silence — **SMS fail-soft compensates** (the message body is also sent as SMS, recipient guaranteed to receive). On non-Samsung devices (Pixel/AOSP, LineageOS, rooted) this is expected to work normally. - **OBEX-FTP browse not advertised.** Use `bt-send` to push files instead. ### ofono + PipeWire (Debian 13, Ubuntu 24.04) - **Two-way SCO audio in calls is blocked.** ofono 2.16 + PipeWire 1.4.x + libspa-bluetooth 1.4.x do not cooperate for HFP audio routing on current Debian. Outgoing calls work — the audio just routes through the phone earpiece, not the host's speaker/mic. Tested on both BCM43142 BT 4.0 and RTL8761B BT 5.1 adapters. `paired-sco-agent` is shipped as experimental — see `docs/ARCHITECTURE.md`. - **A2DP source profile (phone music → host speaker)** is blocked by the same conflict. Receive (host as sink) works; source does not. ### General - The "Hi Agent," LLM trigger is **opt-in** via `paired.conf` and bound to a **whitelist**. Default config has the whitelist empty, which keeps the feature off until the user explicitly trusts a number. - Auto-unlock is **opt-in only**. Storing a phone PIN on the host is a security trade — see `paired.conf.example` for the warning. ## Architecture notes - ofono owns HFP. PipeWire bluez monitor loaded but A2DP-source profile blocked by ofono/PipeWire HFP backend conflict — known trade-off, documented in `docs/ARCHITECTURE.md`. - `bt-agent.service` runs as a system service to handle pairing PIN/passkey requests. - The `paired-*` wrappers are the agent-facing interface; the underlying `bt-*` tools are CLI primitives that wrap BlueZ D-Bus and ofono D-Bus directly. Wrappers add JSON output, trust gating, fail-soft behaviour, and Telegram integration. ## Hardware compatibility See `docs/HARDWARE-COMPATIBILITY.md` for the full matrix. Tested combinations: | Phone | Android | What works | What's blocked | |---|---|---|---| | Samsung Note 9 | 10 / OneUI 12 | Pairing, contacts, SMS receive, outgoing calls, media, file push, PAN, ADB SMS send | In-call TTS, two-way SCO, MAP send, A2DP source | | Adapter | Type | Status | |---|---|---| | BCM43142A0 | Internal BT 4.0 | All features tested working | | RTL8761B | USB BT 5.1 | All features tested working | ## Setup checklist (for first-time users) 1. **Pair your phone:** ```bash ~/bin/bt-list --scan 10 # find your phone in the scan output ~/bin/bt-pair <MAC> --connect # pair, trust, connect ``` 2. **Write your config:** ```bash cp config-templates/paired.conf.example ~/.config/paired/paired.conf $EDITOR ~/.config/paired/paired.conf # set phone_bt_mac, adapter, etc. ``` 3. **Set up the trusted-numbers list (optional, recommended):** ```bash cp config-templates/trusted-numbers.conf.example ~/.config/paired/trusted-numbers.conf ~/bin/paired-trusted add 07911123456 "main mobile" ~/bin/paired-trusted list ``` 4. **Enable the systemd user services you want:** ```bash systemctl --user enable --now paired-sms-watch.service # real-time SMS push systemctl --user enable --now paired-call-watch.service # incoming call alerts systemctl --user enable --now paired-sms-command-hook.service # /sms /phone Telegram commands ``` 5. **Verify:** ```bash ~/bin/bt-test # 10-check stack health ``` If everything's green, the agent is ready to use the skill.

path: bin/bt-adapters.py
content: #!/usr/bin/env python3
"""bt-adapters — Show all BlueZ adapters on this host.

Used by Agent to know what BT hardware is available before any operation.
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bt_lib import list_adapters, adapter_health, detect_profiles


def main() -> int:
    ap = argparse.ArgumentParser(description="List all BlueZ adapters on this host")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    adapters = list_adapters()
    if args.json:
        for a in adapters:
            a["health"] = adapter_health(a["name"])
            a["profiles"] = detect_profiles(a["uuids"])
        print(json.dumps(adapters, indent=2))
        return 0

    if not adapters:
        print("No BlueZ adapters present.")
        return 1
    print(f"  {'NAME':<6}  {'BD ADDRESS':<17}  {'STATE':<6}  {'ALIAS':<20}  Profiles")
    print(f"  {'-'*6}  {'-'*17}  {'-'*6}  {'-'*20}  --------")
    for a in adapters:
        h = adapter_health(a["name"])
        state = "OK" if h["ok"] else "DEAD"
        profs = ", ".join(detect_profiles(a["uuids"]))[:40] or "-"
        print(f"  {a['name']:<6}  {a['address']:<17}  {state:<6}  {a['alias']:<20}  {profs}")
        if not h["ok"]:
            print(f"    └─ {h['reason']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-adb-battery.py
content: #!/usr/bin/env python3
"""bt-adb-battery — accurate battery state via dumpsys.

  bt-adb-battery
  bt-adb-battery --json
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_adb


def main() -> int:
    ap = argparse.ArgumentParser(description="Phone battery via ADB dumpsys")
    ap.add_argument("--serial")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    try:
        b = bt_adb.battery(serial=args.serial)
    except Exception as e:
        sys.stderr.write(f"battery failed: {e}\n")
        return 1

    if args.json:
        # Drop raw to keep output clean
        b.pop("raw", None)
        print(json.dumps(b, indent=2))
        return 0

    print(f"Level:        {b['level_pct']}%")
    print(f"Voltage:      {b['voltage_mv']} mV")
    print(f"Temperature:  {b['temperature_c']}°C")
    src = []
    if b["ac_powered"]: src.append("AC")
    if b["usb_powered"]: src.append("USB")
    if b["wireless_powered"]: src.append("wireless")
    print(f"Charging:     {' + '.join(src) if src else 'no (on battery)'}")
    print(f"Technology:   {b.get('technology', '?')}")
    print(f"Health:       {b.get('health', '?')}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-adb-control.py
content: #!/usr/bin/env python3
"""bt-adb-launch / bt-adb-type / bt-adb-media — phone control via ADB."""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_adb


def main() -> int:
    invoked = Path(sys.argv[0]).name
    if "launch" in invoked:
        ap = argparse.ArgumentParser(description="Launch app via ADB")
        ap.add_argument("target",
                        help="Package (com.foo.bar) or component (com.foo/.Activity)")
        ap.add_argument("--serial")
        args = ap.parse_args()
        try:
            r = bt_adb.launch_app(args.target, serial=args.serial)
        except Exception as e:
            sys.stderr.write(f"launch failed: {e}\n")
            return 1
        print(r["stdout"] or "OK")
        return 0

    if "type" in invoked:
        ap = argparse.ArgumentParser(description="Type text into focused field")
        ap.add_argument("text")
        ap.add_argument("--serial")
        args = ap.parse_args()
        try:
            bt_adb.type_text(args.text, serial=args.serial)
        except Exception as e:
            sys.stderr.write(f"type failed: {e}\n")
            return 1
        print("OK")
        return 0

    if "media" in invoked:
        ap = argparse.ArgumentParser(description="Media transport via ADB")
        ap.add_argument("action", nargs="?", default="status",
                        choices=["play", "pause", "play-pause",
                                 "next", "previous", "stop", "status"])
        ap.add_argument("--serial")
        ap.add_argument("--json", action="store_true")
        args = ap.parse_args()
        try:
            if args.action == "status":
                r = bt_adb.media_status(serial=args.serial)
                if args.json:
                    print(json.dumps(r, indent=2))
                else:
                    if not r["sessions"]:
                        print("(no media sessions)")
                    for s in r["sessions"]:
                        print(f"  {s.get('package', '?'):<30}  "
                              f"state={s.get('state', '?'):<10}  "
                              f"{s.get('title', '')}")
                return 0
            r = bt_adb.media_dispatch(args.action, serial=args.serial)
            print(f"{args.action}: {r['stdout'] or 'OK'}")
        except Exception as e:
            sys.stderr.write(f"media failed: {e}\n")
            return 1
        return 0

    sys.stderr.write(f"unknown invocation: {invoked}\n")
    return 2


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-adb-notif.py
content: #!/usr/bin/env python3
"""bt-adb-notif — list active notifications on the phone.

  bt-adb-notif
  bt-adb-notif --json
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_adb


def main() -> int:
    ap = argparse.ArgumentParser(description="Active notifications via ADB")
    ap.add_argument("--serial")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    try:
        notifs = bt_adb.notifications(serial=args.serial)
    except Exception as e:
        sys.stderr.write(f"failed: {e}\n")
        return 1

    if args.json:
        print(json.dumps(notifs, indent=2))
        return 0

    if not notifs:
        print("(no notifications)")
        return 0
    print(f"  {'PKG':<30}  {'TITLE':<30}  TEXT")
    for n in notifs:
        pkg = (n.get("package") or "?")[-30:]
        title = (n.get("title") or "")[:30]
        text = (n.get("text") or "")[:60]
        print(f"  {pkg:<30}  {title:<30}  {text}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-adb-screenshot.py
content: #!/usr/bin/env python3
"""bt-adb-screenshot — capture phone screen to a local PNG.

  bt-adb-screenshot
  bt-adb-screenshot --to /tmp/phone.png
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_adb


def main() -> int:
    ap = argparse.ArgumentParser(description="Phone screenshot via ADB")
    ap.add_argument("--to", default="~/Downloads/bluetooth/phone-screen.png")
    ap.add_argument("--serial")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    try:
        r = bt_adb.screenshot(local_path=args.to, serial=args.serial)
    except Exception as e:
        sys.stderr.write(f"screenshot failed: {e}\n")
        return 1

    if args.json:
        print(json.dumps(r, indent=2))
    else:
        print(f"saved: {r['saved_to']}  ({r['size_bytes']} bytes)")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-adb-setup.py
content: #!/usr/bin/env python3
"""bt-adb-setup — first-run helper.

Walks through the steps to get an Android phone working with the bluetooth
skill via ADB on .86. After this completes, the other bt-adb-* tools work.

  bt-adb-setup            # interactive
  bt-adb-setup --check    # just verify current state, no changes
"""
from __future__ import annotations
import argparse, sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_adb


def main() -> int:
    ap = argparse.ArgumentParser(description="ADB-companion first-run setup")
    ap.add_argument("--check", action="store_true",
                    help="Just verify, don't prompt for next steps")
    args = ap.parse_args()

    print("=== ADB binary ===")
    try:
        v = bt_adb._adb("version", timeout=5)
        print("  " + v.splitlines()[0])
    except Exception as e:
        print(f"  ADB not working: {e}")
        return 1

    print("\n=== Devices ===")
    devs = bt_adb.list_devices()
    if not devs:
        print("  No devices.")
        if not args.check:
            print("  Plug phone in via USB to .86 directly.")
            print("  On phone: enable Developer options + USB debugging, accept the prompt.")
        return 1

    ok_devs = [d for d in devs if d.get("state") == "device"]
    bad_devs = [d for d in devs if d.get("state") != "device"]
    for d in ok_devs:
        print(f"  OK   {d['serial']}  model={d.get('model', '?')}")
    for d in bad_devs:
        print(f"  --   {d['serial']}  state={d.get('state')}  "
              f"(if 'unauthorized', accept the prompt on the phone)")

    if not ok_devs:
        return 1

    print("\n=== Phone identity ===")
    info = bt_adb.device_info()
    for k, v in info.items():
        print(f"  {k:<18}  {v}")

    print("\n=== Battery ===")
    b = bt_adb.battery()
    print(f"  level:    {b['level_pct']}%")
    print(f"  voltage:  {b['voltage_mv']} mV")
    print(f"  temp:     {b['temperature_c']}°C")
    print(f"  charging: USB={b['usb_powered']} AC={b['ac_powered']}")

    print("\n=== Quick capability check ===")
    capabilities = []
    # SMS read
    try:
        sms = bt_adb.sms_inbox(limit=1)
        capabilities.append(("SMS read (inbox)", "OK" if sms else "no messages",
                              "bt-adb-sms-list"))
    except Exception as e:
        capabilities.append(("SMS read (inbox)", f"FAIL: {e}", "bt-adb-sms-list"))
    # File push (use empty in-memory file)
    try:
        import tempfile
        with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
            f.write("hello")
            tmp = f.name
        bt_adb.push(tmp, "/sdcard/Download/.paired-probe.txt")
        bt_adb.shell("rm /sdcard/Download/.paired-probe.txt")
        Path(tmp).unlink(missing_ok=True)
        capabilities.append(("File push", "OK", "bt-adb-push"))
    except Exception as e:
        capabilities.append(("File push", f"FAIL: {e}", "bt-adb-push"))
    # Notifications dumpsys
    try:
        bt_adb.shell("dumpsys notification | head -1", timeout=5)
        capabilities.append(("Notifications (dumpsys)", "OK", "bt-adb-notif"))
    except Exception as e:
        capabilities.append(("Notifications (dumpsys)", f"FAIL: {e}", "bt-adb-notif"))

    for name, status, tool in capabilities:
        sym = "OK  " if status == "OK" else "WARN"
        print(f"  [{sym}]  {name:<28}  {status:<22}  → {tool}")

    print("\nSetup complete. Try:  bt-adb-sms-list   bt-adb-battery   bt-adb-screenshot")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-adb-sms.py
content: #!/usr/bin/env python3
"""bt-adb-sms — SMS read/send via ADB (works around Samsung MAP-send block).

  bt-adb-sms-list                       # last 20 inbox SMS
  bt-adb-sms-list --limit 50
  bt-adb-sms-list --sent                # sent folder
  bt-adb-sms-list --json
  bt-adb-sms-send 07911123456 "hi"      # opens SMS app pre-populated; user taps send
  bt-adb-sms-send 07911... "hi" --silent  # try service call (often denied on user devices)
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_adb


def main() -> int:
    invoked = Path(sys.argv[0]).name
    if "send" in invoked:
        ap = argparse.ArgumentParser(description="Send SMS via ADB")
        ap.add_argument("number")
        ap.add_argument("text")
        ap.add_argument("--silent", action="store_true",
                        help="Try service-call SMS send (often denied — needs default-SMS-app)")
        ap.add_argument("--serial")
        ap.add_argument("--json", action="store_true")
        args = ap.parse_args()
        try:
            if args.silent:
                r = bt_adb.sms_send_silent(args.number, args.text, serial=args.serial)
            else:
                r = bt_adb.sms_send(args.number, args.text, serial=args.serial)
        except Exception as e:
            sys.stderr.write(f"send failed: {e}\n")
            return 1
        if args.json:
            print(json.dumps(r, indent=2))
        else:
            print(f"method: {r['method']}")
            if r.get("note"):
                print(f"note:   {r['note']}")
            print("→ tap Send on the phone to deliver")
        return 0

    # list
    ap = argparse.ArgumentParser(description="List SMS via ADB")
    ap.add_argument("--limit", type=int, default=20)
    ap.add_argument("--sent", action="store_true",
                    help="Sent folder instead of inbox")
    ap.add_argument("--serial")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    try:
        msgs = (bt_adb.sms_sent(limit=args.limit, serial=args.serial)
                if args.sent
                else bt_adb.sms_inbox(limit=args.limit, serial=args.serial))
    except Exception as e:
        sys.stderr.write(f"list failed: {e}\n")
        return 1

    if args.json:
        print(json.dumps(msgs, indent=2))
        return 0

    if not msgs:
        print("(empty)")
        return 0
    print(f"  {'WHEN':<19}  {'FROM':<22}  Body")
    print(f"  {'-'*19}  {'-'*22}  ----")
    for m in msgs:
        ts = m.get("timestamp") or "?"
        addr = (m.get("address") or "?")[:22]
        body = (m.get("body") or "").replace("\n", " ")[:80]
        print(f"  {ts:<19}  {addr:<22}  {body}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-adb-xfer.py
content: #!/usr/bin/env python3
"""bt-adb-push / bt-adb-pull — file transfer that bypasses the broken OBEX path.

  bt-adb-push FILE [REMOTE]            # default: /sdcard/Download/<basename>
  bt-adb-pull REMOTE [LOCAL]           # default: ~/Downloads/bluetooth/<basename>
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_adb


def main() -> int:
    invoked = Path(sys.argv[0]).name
    op = "pull" if "pull" in invoked else "push"

    ap = argparse.ArgumentParser(description=f"ADB {op}")
    if op == "push":
        ap.add_argument("local")
        ap.add_argument("remote", nargs="?",
                        help="Default: /sdcard/Download/<basename>")
    else:
        ap.add_argument("remote")
        ap.add_argument("local", nargs="?",
                        help="Default: ~/Downloads/bluetooth/<basename>")
    ap.add_argument("--serial")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    try:
        if op == "push":
            local = Path(args.local).expanduser()
            remote = args.remote or f"/sdcard/Download/{local.name}"
            r = bt_adb.push(str(local), remote, serial=args.serial)
        else:
            remote = args.remote
            local = (args.local or
                     f"~/Downloads/bluetooth/{Path(remote).name}")
            r = bt_adb.pull(remote, local, serial=args.serial)
    except Exception as e:
        sys.stderr.write(f"{op} failed: {e}\n")
        return 1

    if args.json:
        print(json.dumps(r, indent=2))
    else:
        print(r["stdout"] or f"{op} OK")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-agent.py
content: #!/usr/bin/env python3
"""
bt-agent — BlueZ pairing agent daemon for the OpenClaw `bluetooth` skill.

Runs as a user systemd service. Registers a D-Bus agent so that pair
requests from peer devices (phones, headphones, beacons) are handled
without a human at the keyboard.

Modes:
  --mode pin    (default) KeyboardDisplay agent.
                Surfaces passkey/PIN prompts via:
                  * a JSON file at ~/.cache/bluetooth/pending-passkey.json
                  * stderr (so journalctl shows it)
                Confirms when ~/.cache/bluetooth/confirm exists, denies when
                ~/.cache/bluetooth/deny exists. Times out after 60s -> deny.
  --mode auto   NoInputNoOutput agent. Auto-confirms pairing.
                Works for: BLE peripherals, headphones, speakers, most
                beacons, and Just Works pairings.
                IMPORTANT: --mode auto without --device-filter will accept
                ANY incoming pairing request. Always pair --mode auto with
                --device-filter unless you genuinely want a wide-open agent
                for a brief, supervised pairing window. Pass --i-mean-it to
                acknowledge that risk and use --mode auto without a filter.

Both modes accept any service authorisation (so an A2DP/HFP profile just works
once paired+trusted).

Useful CLI flags:
  --device-filter=AA:BB:CC:DD:EE:FF
        Only auto-confirm pair requests from this MAC; everything else is
        denied. Useful when you want a one-shot "let me pair THIS phone".
"""
from __future__ import annotations

import argparse
import json
import os
import signal
import sys
import time
from pathlib import Path

# Ensure we can import from the same dir
sys.path.insert(0, str(Path(__file__).resolve().parent))

import dbus
import dbus.service
import dbus.mainloop.glib
from gi.repository import GLib

from bt_lib import (
    BLUEZ_SERVICE, AGENT_MANAGER_IFACE, AGENT_IFACE, _device_path_to_mac,
)

AGENT_PATH = "/openclaw/bluetooth/agent"
DEFAULT_CACHE = Path(os.path.expanduser("~/.cache/bluetooth"))
PENDING_FILE = DEFAULT_CACHE / "pending-passkey.json"
CONFIRM_FILE = DEFAULT_CACHE / "confirm"
DENY_FILE = DEFAULT_CACHE / "deny"


def _log(msg: str) -> None:
    sys.stderr.write(f"[bt-agent] {msg}\n")
    sys.stderr.flush()


class BluezAgent(dbus.service.Object):
    def __init__(self, bus, path, mode: str = "auto",
                 device_filter: str | None = None):
        super().__init__(bus, path)
        self.mode = mode
        self.device_filter = device_filter.upper() if device_filter else None
        DEFAULT_CACHE.mkdir(parents=True, exist_ok=True)
        # Wipe any stale confirm/deny markers from a previous invocation
        for f in (PENDING_FILE, CONFIRM_FILE, DENY_FILE):
            f.unlink(missing_ok=True)

    # --- Helpers --------------------------------------------------------
    def _allowed(self, dev_path: str) -> bool:
        """Return True if the device is allowed to pair under our policy."""
        if self.device_filter is None:
            return True
        mac = (_device_path_to_mac(dev_path) or "").upper()
        if mac == self.device_filter:
            return True
        _log(f"deny: {mac} not in filter {self.device_filter!r}")
        return False

    def _wait_for_decision(self, timeout: float = 60.0) -> bool:
        deadline = time.time() + timeout
        while time.time() < deadline:
            if CONFIRM_FILE.exists():
                CONFIRM_FILE.unlink(missing_ok=True)
                return True
            if DENY_FILE.exists():
                DENY_FILE.unlink(missing_ok=True)
                return False
            time.sleep(0.25)
        _log("decision timeout — denying")
        return False

    def _publish_pending(self, payload: dict) -> None:
        with open(PENDING_FILE, "w") as f:
            json.dump(payload, f, indent=2)
        _log(f"pending decision: {payload}")
        _log(f"to confirm: touch {CONFIRM_FILE}")
        _log(f"to deny:    touch {DENY_FILE}")

    # --- D-Bus methods (BlueZ Agent1 interface) -------------------------
    @dbus.service.method(AGENT_IFACE, in_signature="", out_signature="")
    def Release(self):
        _log("Release")

    @dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="")
    def AuthorizeService(self, device, uuid):
        _log(f"AuthorizeService {device} uuid={uuid} -> ALLOW")
        # Trusting = auto-authorising future profile use
        if not self._allowed(device):
            raise dbus.DBusException(
                "org.bluez.Error.Rejected", "device filter blocked"
            )

    @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="s")
    def RequestPinCode(self, device):
        if self.mode == "auto":
            _log(f"RequestPinCode {device} -> 0000 (auto)")
            return "0000"
        mac = _device_path_to_mac(device) or device
        self._publish_pending({"event": "RequestPinCode", "mac": mac})
        _log(f"RequestPinCode {mac} -> waiting decision")
        ok = self._wait_for_decision()
        if not ok:
            raise dbus.DBusException("org.bluez.Error.Rejected", "user denied")
        return "0000"

    @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="u")
    def RequestPasskey(self, device):
        if self.mode == "auto":
            _log(f"RequestPasskey {device} -> 0 (auto)")
            return dbus.UInt32(0)
        mac = _device_path_to_mac(device) or device
        self._publish_pending({"event": "RequestPasskey", "mac": mac})
        ok = self._wait_for_decision()
        if not ok:
            raise dbus.DBusException("org.bluez.Error.Rejected", "user denied")
        return dbus.UInt32(0)

    @dbus.service.method(AGENT_IFACE, in_signature="ouq", out_signature="")
    def DisplayPasskey(self, device, passkey, entered):
        mac = _device_path_to_mac(device) or device
        _log(f"DisplayPasskey {mac} passkey={passkey:06d} entered={entered}")
        self._publish_pending({
            "event": "DisplayPasskey",
            "mac": mac,
            "passkey": int(passkey),
            "entered": int(entered),
        })

    @dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="")
    def DisplayPinCode(self, device, pincode):
        mac = _device_path_to_mac(device) or device
        _log(f"DisplayPinCode {mac} pin={pincode}")
        self._publish_pending({
            "event": "DisplayPinCode",
            "mac": mac,
            "pincode": str(pincode),
        })

    @dbus.service.method(AGENT_IFACE, in_signature="ou", out_signature="")
    def RequestConfirmation(self, device, passkey):
        mac = _device_path_to_mac(device) or device
        if not self._allowed(device):
            raise dbus.DBusException(
                "org.bluez.Error.Rejected", "device filter blocked"
            )
        if self.mode == "auto":
            _log(f"RequestConfirmation {mac} passkey={passkey:06d} -> AUTO-CONFIRM")
            return
        self._publish_pending({
            "event": "RequestConfirmation",
            "mac": mac,
            "passkey": int(passkey),
        })
        ok = self._wait_for_decision()
        if not ok:
            raise dbus.DBusException("org.bluez.Error.Rejected", "user denied")
        _log(f"RequestConfirmation {mac} -> CONFIRMED")

    @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="")
    def RequestAuthorization(self, device):
        mac = _device_path_to_mac(device) or device
        if not self._allowed(device):
            raise dbus.DBusException(
                "org.bluez.Error.Rejected", "device filter blocked"
            )
        if self.mode == "auto":
            _log(f"RequestAuthorization {mac} -> AUTO-ALLOW")
            return
        self._publish_pending({"event": "RequestAuthorization", "mac": mac})
        ok = self._wait_for_decision()
        if not ok:
            raise dbus.DBusException("org.bluez.Error.Rejected", "user denied")

    @dbus.service.method(AGENT_IFACE, in_signature="", out_signature="")
    def Cancel(self):
        _log("Cancel")
        for f in (PENDING_FILE,):
            f.unlink(missing_ok=True)


def main() -> int:
    ap = argparse.ArgumentParser(description="BlueZ pairing agent for the bluetooth skill")
    # Default is now `pin` (interactive) for safety. `auto` requires explicit opt-in.
    ap.add_argument("--mode", default="pin", choices=["auto", "pin"])
    ap.add_argument("--device-filter", default=None,
                    help="Only auto-confirm pairings from this MAC")
    ap.add_argument("--i-mean-it", action="store_true",
                    help="Required to use --mode auto without --device-filter")
    ap.add_argument("--make-pairable", action="store_true",
                    help="Set Pairable=on, PairableTimeout=180 on hci0 then exit")
    args = ap.parse_args()

    # Safety check: refuse wide-open auto-pairing unless user explicitly opted in.
    if args.mode == "auto" and not args.device_filter and not args.i_mean_it:
        _log("REFUSING: --mode auto without --device-filter is wide-open pairing.")
        _log("Either: (a) supply --device-filter MAC, or (b) pass --i-mean-it to")
        _log("acknowledge the risk and proceed (e.g. for a brief supervised pair).")
        return 2

    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    bus = dbus.SystemBus()

    if args.make_pairable:
        from bt_lib import adapter_path, ADAPTER_IFACE, PROPS_IFACE
        ap_p = adapter_path("hci0")
        aobj = bus.get_object(BLUEZ_SERVICE, ap_p)
        props = dbus.Interface(aobj, PROPS_IFACE)
        props.Set(ADAPTER_IFACE, "Pairable", dbus.Boolean(True))
        props.Set(ADAPTER_IFACE, "PairableTimeout", dbus.UInt32(180))
        props.Set(ADAPTER_IFACE, "Discoverable", dbus.Boolean(True))
        props.Set(ADAPTER_IFACE, "DiscoverableTimeout", dbus.UInt32(180))
        _log("hci0: Pairable=on Discoverable=on (180s)")
        return 0

    agent = BluezAgent(bus, AGENT_PATH, mode=args.mode,
                       device_filter=args.device_filter)
    capability = "NoInputNoOutput" if args.mode == "auto" else "KeyboardDisplay"
    manager = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/org/bluez"),
                             AGENT_MANAGER_IFACE)
    try:
        manager.RegisterAgent(AGENT_PATH, capability)
    except dbus.DBusException as e:
        if "AlreadyExists" in str(e):
            _log("agent already registered (probably another instance) — exiting")
            return 1
        raise
    manager.RequestDefaultAgent(AGENT_PATH)
    _log(f"registered as default agent (capability={capability}, mode={args.mode}"
         + (f", filter={args.device_filter}" if args.device_filter else "")
         + ")")

    loop = GLib.MainLoop()

    def _shutdown(*_a):
        _log("shutting down")
        try:
            manager.UnregisterAgent(AGENT_PATH)
        except dbus.DBusException:
            pass
        loop.quit()

    signal.signal(signal.SIGTERM, _shutdown)
    signal.signal(signal.SIGINT, _shutdown)

    try:
        loop.run()
    finally:
        try:
            manager.UnregisterAgent(AGENT_PATH)
        except dbus.DBusException:
            pass
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-audio.py
content: #!/usr/bin/env python3
"""bt-audio — Show audio state, find BT audio sinks/sources, switch profiles.

Examples:
  bt-audio                         # Full audio status (sinks + sources + BT devices)
  bt-audio --sink AA:BB:CC:DD:EE:FF   # Find PipeWire sink for this paired BT MAC
  bt-audio --set-default 55       # Make sink id 55 the system default
  bt-audio --profiles AA:BB:CC:..  # List BT profiles for a device (a2dp / hfp / off)
  bt-audio --json
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_audio


def main() -> int:
    ap = argparse.ArgumentParser(description="BT audio routing diagnostics + control")
    ap.add_argument("--sink", help="Find PipeWire sink for this BT MAC")
    ap.add_argument("--source", help="Find PipeWire source for this BT MAC")
    ap.add_argument("--set-default", type=int, metavar="ID",
                    help="Set wpctl object id as default sink")
    ap.add_argument("--profiles", help="List BT profiles for this MAC")
    ap.add_argument("--set-profile", metavar="MAC,IDX", help="Set BT device to profile index, e.g. 'AA:BB:..,1'")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    if not bt_audio.have_pipewire():
        sys.stderr.write("error: wpctl/pw-cli not in PATH (PipeWire not installed?)\n")
        return 2

    if args.set_default is not None:
        bt_audio.set_default_sink(args.set_default)
        print(f"default sink set to id {args.set_default}")
        return 0

    if args.set_profile:
        mac, idx = args.set_profile.split(",")
        bt_audio.set_bt_profile(mac.strip(), int(idx))
        print(f"profile set on {mac.strip()} -> index {idx}")
        return 0

    if args.profiles:
        profiles = bt_audio.list_profiles_for_bt(args.profiles)
        if args.json:
            print(json.dumps(profiles, indent=2))
            return 0
        if not profiles:
            print(f"No BT profiles for {args.profiles}. Connected?")
            return 1
        for p in profiles:
            print(p)
        return 0

    if args.sink:
        s = bt_audio.find_bluez_sink(args.sink)
        if args.json:
            print(json.dumps(s, indent=2))
        elif s is None:
            print(f"No PipeWire sink for {args.sink} (device not connected with A2DP profile?)")
            return 1
        else:
            print(f"id={s['id']}  node={s['node_name']}")
            print(f"description: {s['node_description']}")
        return 0

    if args.source:
        s = bt_audio.find_bluez_source(args.source)
        if args.json:
            print(json.dumps(s, indent=2))
        elif s is None:
            print(f"No PipeWire source for {args.source}")
            return 1
        else:
            print(f"id={s['id']}  node={s['node_name']}")
        return 0

    # Default: full status
    sinks = bt_audio.list_sinks()
    sources = bt_audio.list_sources()
    if args.json:
        print(json.dumps({"sinks": sinks, "sources": sources}, indent=2))
        return 0
    print("Audio Sinks:")
    for s in sinks:
        mark = " *" if s["default"] else "  "
        print(f"  {mark} id={s['id']:<4}  {s['name']}")
    print("\nAudio Sources:")
    for s in sources:
        mark = " *" if s["default"] else "  "
        print(f"  {mark} id={s['id']:<4}  {s['name']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-battery.py
content: #!/usr/bin/env python3
"""bt-battery — Read a paired phone's battery + signal via BlueZ Battery1
or via ofono Handsfree HF Indicators.

Examples:
  bt-battery MAC
  bt-battery MAC --json
  bt-battery --all                  # every connected device with battery
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))

import dbus
from bt_lib import (BLUEZ_SERVICE, OM_IFACE, PROPS_IFACE, get_bus,
                    adapter_path, _mac_to_device_path, _device_path_to_mac,
                    list_adapters)

BATTERY_IFACE = "org.bluez.Battery1"


def _dbus_to_py(v):
    if isinstance(v, dbus.Dictionary):
        return {str(k): _dbus_to_py(x) for k, x in v.items()}
    if isinstance(v, (dbus.Array, list, tuple)):
        return [_dbus_to_py(x) for x in v]
    if isinstance(v, (dbus.String, dbus.ObjectPath)):
        return str(v)
    if isinstance(v, dbus.Boolean):
        return bool(v)
    if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64,
                       dbus.UInt16, dbus.UInt32, dbus.UInt64,
                       dbus.Byte)):
        return int(v)
    return v


def battery_for(mac: str, adapter: str = "hci0") -> dict | None:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        return None
    dpath = _mac_to_device_path(apath, mac)
    om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), OM_IFACE)
    for path, ifaces in om.GetManagedObjects().items():
        if path != dpath:
            continue
        if BATTERY_IFACE in ifaces:
            return {
                "mac": mac.upper(),
                "percentage": _dbus_to_py(ifaces[BATTERY_IFACE].get("Percentage")),
                "source": _dbus_to_py(ifaces[BATTERY_IFACE].get("Source")),
            }
    return None


def all_batteries() -> list[dict]:
    bus = get_bus()
    om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), OM_IFACE)
    out = []
    for path, ifaces in om.GetManagedObjects().items():
        if BATTERY_IFACE not in ifaces:
            continue
        mac = _device_path_to_mac(path)
        out.append({
            "mac": mac,
            "path": str(path),
            "percentage": _dbus_to_py(ifaces[BATTERY_IFACE].get("Percentage")),
            "source": _dbus_to_py(ifaces[BATTERY_IFACE].get("Source")),
        })
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description="Battery level of paired BT devices")
    ap.add_argument("mac", nargs="?")
    ap.add_argument("--all", action="store_true",
                    help="Every device with battery info")
    ap.add_argument("--adapter", default="hci0")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    if args.all:
        bs = all_batteries()
        if args.json:
            print(json.dumps(bs, indent=2))
        elif not bs:
            print("No connected devices report battery.")
        else:
            for b in bs:
                src = b.get("source") or "?"
                print(f"  {b['mac']}  {b.get('percentage', '?')}%  ({src})")
        return 0

    if not args.mac:
        sys.stderr.write("MAC required (or use --all)\n")
        return 2

    b = battery_for(args.mac, args.adapter)
    if args.json:
        print(json.dumps(b, indent=2))
        return 0
    if b is None:
        print(f"No battery data for {args.mac}.")
        print("  Hint: phone must be connected and have advertised the Battery service")
        print("  (Some phones only do this via HFP HF Indicators after a call connects)")
        return 1
    print(f"{b['mac']}  {b['percentage']}%  source={b['source']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-browse.py
content: #!/usr/bin/env python3
"""bt-browse — OBEX FTP: list / pull files from a paired BT device's filesystem.

Examples:
  bt-browse AA:BB:CC:DD:EE:FF                      # list root folder
  bt-browse AA:BB:CC:DD:EE:FF /Music               # list a sub-folder
  bt-browse AA:BB:CC:DD:EE:FF --pull /Music/song.mp3
  bt-browse AA:BB:CC:DD:EE:FF --pull /Music/song.mp3 --save-to ./song.mp3

Requires the device to support OBEX FTP (UUID 0000-1106). Many phones
expose this only after granting filesystem access in pair settings.
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_obex


def main() -> int:
    ap = argparse.ArgumentParser(description="OBEX FTP browse / pull")
    ap.add_argument("mac")
    ap.add_argument("path", nargs="?", default="/", help="Folder to list (default /)")
    ap.add_argument("--pull", help="Filename (relative to listed folder) to pull")
    ap.add_argument("--save-to", help="Local path to save pulled file")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    if args.pull:
        try:
            r = bt_obex.pull_file(args.mac, args.pull, save_to=args.save_to)
        except Exception as e:
            sys.stderr.write(f"PULL FAILED: {e}\n")
            return 1
        if args.json:
            print(json.dumps(r, indent=2))
        else:
            print(f"saved {args.pull} -> {r['saved_to']}  status={r['status']}")
        return 0 if r["status"] == "complete" else 1

    try:
        listing = bt_obex.list_folder(args.mac, args.path)
    except Exception as e:
        sys.stderr.write(f"BROWSE FAILED: {e}\n")
        return 1

    if args.json:
        print(json.dumps(listing, indent=2))
        return 0
    if not listing:
        print(f"(empty: {args.path})")
        return 0
    print(f"  {'TYPE':<8}  {'SIZE':>10}  Name")
    for e in listing:
        t = e.get("Type") or ("folder" if e.get("Folder") else "file")
        sz = e.get("Size", "")
        print(f"  {t:<8}  {sz:>10}  {e.get('Name', '?')}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-call.py
content: #!/usr/bin/env python3
"""bt-call — Make a call through a paired phone (HFP via ofono).

LOW-LEVEL PRIMITIVE. Most callers should use ~/bin/paired-call instead, which
adds JSON output, modem auto-resolution, and Telegram replies.

v1.0.4: gated by trusted-numbers allowlist. To dial a number that is not in
`~/.config/paired/trusted-numbers.conf`, pass --confirm. This addresses the
OpenClaw scanner finding that the low-level dial primitive accepted arbitrary
numbers without a safety check.

Examples:
  bt-call 07911123456                          # only if trusted, else refuses
  bt-call 07911123456 --confirm                # one-shot override
  bt-call 07911123456 --modem AA:BB:CC:DD:EE:FF
  bt-call --hangup                             # hang up everything
  bt-call --status                             # show current calls
"""
from __future__ import annotations
import argparse, sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_telephony as bt

TRUSTED_NUMBERS_FILE = Path.home() / ".config" / "paired" / "trusted-numbers.conf"


def _normalize_uk(num: str) -> str:
    """Normalise a UK number for trusted-list comparison."""
    n = num.strip().replace(" ", "").replace("-", "")
    if n.startswith("+44"):
        n = "0" + n[3:]
    elif n.startswith("0044"):
        n = "0" + n[4:]
    elif n.startswith("44") and len(n) == 12:
        n = "0" + n[2:]
    return n


def _load_trusted() -> set[str]:
    s: set[str] = set()
    if not TRUSTED_NUMBERS_FILE.exists():
        return s
    try:
        for line in TRUSTED_NUMBERS_FILE.read_text().splitlines():
            line = line.split("#", 1)[0].strip()
            if not line:
                continue
            tok = line.split()[0] if line.split() else ""
            if tok:
                s.add(_normalize_uk(tok))
    except OSError:
        pass
    return s


def _is_trusted(number: str) -> bool:
    return _normalize_uk(number) in _load_trusted()


def _resolve_modem(mac_arg: str | None) -> dict | None:
    modems = bt.list_modems()
    if not modems:
        return None
    if mac_arg:
        return bt.modem_for_mac(mac_arg)
    return modems[0]


def main() -> int:
    ap = argparse.ArgumentParser(description="Make / answer / hang up calls (LOW-LEVEL; prefer paired-call)")
    ap.add_argument("number", nargs="?", help="Phone number to dial")
    ap.add_argument("--modem", help="Specific paired phone MAC (default: first modem)")
    ap.add_argument("--answer", action="store_true", help="Answer incoming call")
    ap.add_argument("--hangup", action="store_true", help="Hang up all calls")
    ap.add_argument("--status", action="store_true", help="Show current calls")
    ap.add_argument("--hide-id", action="store_true", help="Hide caller ID")
    ap.add_argument("--confirm", action="store_true",
                    help="Override trusted-numbers gate for this dial")
    args = ap.parse_args()

    modem = _resolve_modem(args.modem)
    if modem is None:
        sys.stderr.write("No ofono modem available. Pair a phone with HFP first:\n")
        sys.stderr.write("  bt-pair <phone-mac> --connect\n")
        return 2

    path = modem["path"]

    if args.status:
        calls = bt.list_calls(path)
        if not calls:
            print(f"{path}: no active calls")
            return 0
        for c in calls:
            print(f"  {c.get('LineIdentification', '?')} state={c.get('State', '?')}")
        return 0

    if args.hangup:
        n = bt.hangup_all(path)
        print(f"hung up {n} call(s)")
        return 0

    if args.answer:
        cp = bt.answer_first(path)
        if cp:
            print(f"answered {cp}")
            return 0
        print("no incoming call to answer")
        return 1

    if not args.number:
        sys.stderr.write("error: number required (or use --hangup / --answer / --status)\n")
        return 2

    # v1.0.4: trust gating. Refuse to dial untrusted numbers without --confirm.
    if not _is_trusted(args.number) and not args.confirm:
        sys.stderr.write(
            f"REFUSING: {args.number} is not in {TRUSTED_NUMBERS_FILE}.\n"
            "To dial: either (a) add it via `paired-trusted add <number>`,\n"
            "or (b) pass --confirm to override for this one call.\n"
        )
        return 2

    print(f"dialing {args.number} via {path}...")
    try:
        cp = bt.dial(path, args.number, hide_caller_id=args.hide_id)
    except Exception as e:
        sys.stderr.write(f"DIAL FAILED: {e}\n")
        return 1
    print(f"call started: {cp}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-connect.py
content: #!/usr/bin/env python3
"""bt-connect / bt-disconnect — Open or close an active link to a paired device.

Same script handles both via $0; symlink-aware.
"""
from __future__ import annotations
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bt_lib import device_op, get_device, adapter_health


def main() -> int:
    invoked = Path(sys.argv[0]).name
    op = "Disconnect" if "disconnect" in invoked else "Connect"
    ap = argparse.ArgumentParser(description=f"{op} a paired BT device")
    ap.add_argument("mac")
    ap.add_argument("--adapter", default="hci0")
    ap.add_argument("--timeout", type=float, default=30.0)
    args = ap.parse_args()

    health = adapter_health(args.adapter)
    if not health["ok"]:
        sys.stderr.write(f"adapter unhealthy: {health['reason']}\n")
        return 2

    try:
        d = get_device(args.mac, args.adapter)
    except RuntimeError as e:
        sys.stderr.write(f"error: {e}\n")
        return 1

    if op == "Connect" and d.get("connected"):
        print(f"{args.mac} already connected.")
        return 0
    if op == "Disconnect" and not d.get("connected"):
        print(f"{args.mac} already disconnected.")
        return 0

    print(f"{op}ing {args.mac} on {args.adapter}...")
    try:
        device_op(args.mac, op, adapter=args.adapter, timeout=args.timeout)
    except RuntimeError as e:
        sys.stderr.write(f"{op.upper()} FAILED: {e}\n")
        return 1
    d2 = get_device(args.mac, args.adapter)
    print(f"  state: connected={d2.get('connected')} paired={d2.get('paired')}")
    return 0 if (op == "Connect") == bool(d2.get("connected")) else 1


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-contacts.py
content: #!/usr/bin/env python3
"""bt-contacts — Browse contacts via PBAP from a paired phone.

Examples:
  bt-contacts AA:BB:CC:DD:EE:FF                  # list contacts (first 25 names)
  bt-contacts AA:BB:CC:DD:EE:FF --max 0           # all contacts
  bt-contacts AA:BB:CC:DD:EE:FF --pull            # pull entire phonebook to .vcf
  bt-contacts AA:BB:CC:DD:EE:FF --pull --save-to ./contacts.vcf
  bt-contacts AA:BB:CC:DD:EE:FF --repo sim        # SIM card contacts
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_obex_msg as obex_msg


def main() -> int:
    ap = argparse.ArgumentParser(description="PBAP — phone contacts")
    ap.add_argument("mac")
    ap.add_argument("--repo", default="internal",
                    choices=["internal", "sim"], help="Phone book to read")
    ap.add_argument("--max", type=int, default=25, help="Max contacts (0 = all)")
    ap.add_argument("--pull", action="store_true",
                    help="Pull entire phonebook as .vcf file")
    ap.add_argument("--save-to", help="Output .vcf path (with --pull)")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    if args.pull:
        try:
            r = obex_msg.pbap_pull_all_vcards(
                args.mac, repo=args.repo, save_to=args.save_to,
            )
        except Exception as e:
            sys.stderr.write(f"PBAP pull failed: {e}\n")
            return 1
        if args.json:
            print(json.dumps(r, indent=2))
        else:
            print(f"pulled {args.repo} phonebook -> {r['saved_to']}  status={r['status']}")
        return 0 if r["status"] == "complete" else 1

    try:
        contacts = obex_msg.pbap_list_contacts(args.mac, repo=args.repo,
                                                max_count=args.max)
    except Exception as e:
        sys.stderr.write(f"PBAP list failed: {e}\n")
        sys.stderr.write("(phone must have granted Phone Book Access for this host)\n")
        return 1
    if args.json:
        print(json.dumps(contacts, indent=2))
        return 0
    if not contacts:
        print("(empty)")
        return 0
    print(f"  {'HANDLE':<10}  Name")
    for c in contacts:
        print(f"  {c['handle']:<10}  {c['name']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-gatt.py
content: #!/usr/bin/env python3
"""bt-gatt — Read / write GATT characteristics on a connected BLE device.

Examples:
  bt-gatt-read  AA:BB:CC:DD:EE:FF 00002a19-0000-1000-8000-00805f9b34fb   # battery level
  bt-gatt-write AA:BB:CC:DD:EE:FF 0000xxxx-... DEADBEEF                   # hex payload
  bt-gatt-tree  AA:BB:CC:DD:EE:FF                                         # show all services + chars
"""
from __future__ import annotations
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bt_lib import (gatt_read, gatt_write, list_gatt_tree, get_device,
                    adapter_health)


def hex_to_bytes(s: str) -> bytes:
    s = s.replace(" ", "").replace("0x", "").replace(":", "")
    if len(s) % 2:
        s = "0" + s
    return bytes.fromhex(s)


def main() -> int:
    invoked = Path(sys.argv[0]).name
    if "tree" in invoked:
        op = "tree"
    elif "write" in invoked:
        op = "write"
    else:
        op = "read"

    ap = argparse.ArgumentParser(description=f"GATT {op}")
    ap.add_argument("mac")
    if op != "tree":
        ap.add_argument("char_uuid")
    if op == "write":
        ap.add_argument("payload_hex", help="Hex string, e.g. 'DEADBEEF' or '01:02:03'")
        ap.add_argument("--no-response", action="store_true",
                        help="Write without response (faster, no ack)")
    ap.add_argument("--adapter", default="hci0")
    args = ap.parse_args()

    health = adapter_health(args.adapter)
    if not health["ok"]:
        sys.stderr.write(f"adapter unhealthy: {health['reason']}\n")
        return 2

    try:
        d = get_device(args.mac, args.adapter)
    except RuntimeError as e:
        sys.stderr.write(f"error: {e}\n")
        return 1
    if not d.get("connected"):
        sys.stderr.write(f"error: {args.mac} is not connected. Run bt-connect first.\n")
        return 1

    if op == "tree":
        tree = list_gatt_tree(args.mac, args.adapter)
        if not tree:
            print("No GATT services discovered yet — wait a few seconds and retry.")
            return 1
        for svc_uuid, sv in tree.items():
            print(f"service {svc_uuid}")
            for c_uuid, ch in sv["characteristics"].items():
                print(f"  char {c_uuid}  flags={','.join(ch['flags'])}")
                for du, _dv in ch["descriptors"].items():
                    print(f"    desc {du}")
        return 0

    if op == "read":
        try:
            v = gatt_read(args.mac, args.char_uuid, args.adapter)
        except RuntimeError as e:
            sys.stderr.write(f"read failed: {e}\n")
            return 1
        try:
            text = v.decode("utf-8")
            ascii_repr = text if all(32 <= ord(c) < 127 for c in text) else None
        except UnicodeDecodeError:
            ascii_repr = None
        print(f"hex:   {v.hex()}")
        if ascii_repr:
            print(f"ascii: {ascii_repr!r}")
        print(f"bytes: {len(v)}")
        return 0

    if op == "write":
        payload = hex_to_bytes(args.payload_hex)
        try:
            gatt_write(args.mac, args.char_uuid, payload,
                       adapter=args.adapter,
                       with_response=not args.no_response)
        except RuntimeError as e:
            sys.stderr.write(f"write failed: {e}\n")
            return 1
        print(f"wrote {len(payload)} bytes ({'with' if not args.no_response else 'no'} response)")
        return 0
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-info.py
content: #!/usr/bin/env python3
"""bt-info — Detailed information for one device, including GATT tree if connected.

bt-info AA:BB:CC:DD:EE:FF [--adapter hci0] [--json]
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bt_lib import (get_device, list_gatt_tree, detect_profiles,
                    adapter_health)


def main() -> int:
    ap = argparse.ArgumentParser(description="Detailed BT device info")
    ap.add_argument("mac")
    ap.add_argument("--adapter", default="hci0")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    health = adapter_health(args.adapter)
    if not health["ok"]:
        sys.stderr.write(f"adapter unhealthy: {health['reason']}\n")
        return 2

    try:
        d = get_device(args.mac, args.adapter)
    except RuntimeError as e:
        sys.stderr.write(f"error: {e}\n")
        return 1

    d["profiles"] = detect_profiles(d.get("service_uuids", []))
    gatt: dict = {}
    if d.get("connected"):
        try:
            gatt = list_gatt_tree(args.mac, args.adapter)
        except Exception as e:
            gatt = {"error": str(e)}
    d["gatt"] = gatt

    if args.json:
        print(json.dumps(d, indent=2, default=str))
        return 0

    print(f"Device: {d['mac']}  ({d.get('name') or '(unnamed)'})")
    print(f"  Adapter:        {args.adapter}")
    print(f"  Address type:   {d['addr_type']}")
    print(f"  Manufacturer:   {d.get('manuf_name') or '?'}")
    print(f"  Paired:         {d.get('paired')}")
    print(f"  Trusted:        {d.get('trusted')}")
    print(f"  Connected:      {d.get('connected')}")
    if d.get("rssi") is not None:
        print(f"  RSSI:           {d['rssi']} dBm  (~{d.get('distance_m', '?')}m)")
    if d.get("icon"):
        print(f"  Icon:           {d['icon']}")
    if d.get("profiles"):
        print(f"  Profiles:       {', '.join(d['profiles'])}")
    if d.get("service_uuids"):
        print(f"  Service UUIDs:  {len(d['service_uuids'])} advertised")
        for u in d["service_uuids"][:8]:
            print(f"                  {u}")
        if len(d["service_uuids"]) > 8:
            print(f"                  ... and {len(d['service_uuids']) - 8} more")
    if isinstance(gatt, dict) and gatt and "error" not in gatt:
        print(f"\nGATT services ({len(gatt)}):")
        for svc_uuid, sv in gatt.items():
            print(f"  {svc_uuid}")
            for c_uuid, ch in sv["characteristics"].items():
                print(f"    char {c_uuid}  flags={','.join(ch['flags'])}")
    elif isinstance(gatt, dict) and "error" in gatt:
        print(f"\nGATT: not enumerated ({gatt['error']})")
    elif d.get("connected"):
        print("\nGATT: device connected but no services advertised")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-list.py
content: #!/usr/bin/env python3
"""bt-list — Show known/paired/connected BT devices on a given adapter.

Examples:
  bt-list                    # all known devices on hci0
  bt-list --paired           # only paired
  bt-list --connected        # only currently connected
  bt-list --json             # JSON for scripting
  bt-list --adapter hci1     # use a different controller
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bt_lib import (list_adapters, adapter_path, adapter_health,
                    DEVICE_IFACE, OM_IFACE, BLUEZ_SERVICE, get_bus,
                    _props_to_partial, _finalise, _device_path_to_mac,
                    detect_profiles)
import dbus


def _enumerate(adapter: str) -> list[dict]:
    apath = adapter_path(adapter)
    if apath is None:
        raise RuntimeError(f"adapter {adapter!r} not found")
    bus = get_bus()
    om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), OM_IFACE)
    out = []
    for path, ifaces in om.GetManagedObjects().items():
        if not path.startswith(apath + "/") or DEVICE_IFACE not in ifaces:
            continue
        partial = _props_to_partial(ifaces[DEVICE_IFACE])
        mac = partial.get("mac") or _device_path_to_mac(path)
        if not mac:
            continue
        d = _finalise(mac, partial)
        d["profiles"] = detect_profiles(d.get("service_uuids", []))
        out.append(d)
    out.sort(key=lambda d: (
        not d.get("connected"),
        not d.get("paired"),
        -(d.get("rssi") or -127),
        d.get("name") or "",
    ))
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description="List BlueZ-known BT devices")
    ap.add_argument("--adapter", default="hci0")
    ap.add_argument("--paired", action="store_true")
    ap.add_argument("--connected", action="store_true")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    health = adapter_health(args.adapter)
    if not health["ok"]:
        sys.stderr.write(f"adapter {args.adapter} unhealthy: {health['reason']}\n")
        return 2

    devs = _enumerate(args.adapter)
    if args.paired:
        devs = [d for d in devs if d.get("paired")]
    if args.connected:
        devs = [d for d in devs if d.get("connected")]

    if args.json:
        print(json.dumps(devs, indent=2))
        return 0

    if not devs:
        print(f"No matching devices on {args.adapter}.")
        return 0

    print(f"  {'STATE':<10}  {'MAC':<17}  {'Manufacturer':<14}  {'Profiles':<24}  Name")
    print(f"  {'-'*10}  {'-'*17}  {'-'*14}  {'-'*24}  ----")
    for d in devs:
        st = []
        if d.get("connected"):
            st.append("CONN")
        if d.get("paired"):
            st.append("PAIR")
        if d.get("trusted"):
            st.append("TRUST")
        state = "/".join(st) or "-"
        profiles = ", ".join(d.get("profiles", []))[:24]
        print(f"  {state:<10}  {d['mac']:<17}  "
              f"{(d.get('manuf_name') or ''):<14}  "
              f"{profiles:<24}  "
              f"{d.get('name') or '(unnamed)'}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-media.py
content: #!/usr/bin/env python3
"""bt-media — Control music playback on a connected phone via AVRCP.

Examples:
  bt-media MAC                      # show current track + playback status
  bt-media MAC --play
  bt-media MAC --pause
  bt-media MAC --next
  bt-media MAC --prev
  bt-media MAC --json
  bt-media MAC --watch              # follow track changes (Ctrl-C to stop)
"""
from __future__ import annotations
import argparse, json, sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_media


def main() -> int:
    ap = argparse.ArgumentParser(description="AVRCP media control")
    ap.add_argument("mac")
    ap.add_argument("--adapter", default="hci0")
    g = ap.add_mutually_exclusive_group()
    g.add_argument("--play", action="store_true")
    g.add_argument("--pause", action="store_true")
    g.add_argument("--stop", action="store_true")
    g.add_argument("--next", dest="next_", action="store_true")
    g.add_argument("--prev", action="store_true")
    g.add_argument("--ff", action="store_true", help="Fast forward")
    g.add_argument("--rew", action="store_true", help="Rewind")
    g.add_argument("--watch", action="store_true",
                    help="Follow track changes (poll every 2s)")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    action_map = {
        "play": "Play", "pause": "Pause", "stop": "Stop",
        "next_": "Next", "prev": "Previous",
        "ff": "FastForward", "rew": "Rewind",
    }
    for k, method in action_map.items():
        if getattr(args, k, False):
            try:
                bt_media.player_action(args.mac, method, args.adapter)
            except Exception as e:
                sys.stderr.write(f"{method} failed: {e}\n")
                return 1
            print(f"{method} sent")
            time.sleep(0.5)
            # Show new status
            s = bt_media.media_status(args.mac, args.adapter)
            print(f"  → status={s.get('status')} title={s.get('title')!r}")
            return 0

    if args.watch:
        last = None
        try:
            while True:
                s = bt_media.media_status(args.mac, args.adapter)
                key = (s.get("title"), s.get("artist"), s.get("status"))
                if key != last:
                    last = key
                    ts = time.strftime("%H:%M:%S")
                    print(f"[{ts}] {s.get('status', '?'):<8} | "
                          f"{s.get('artist') or '?':<25.25} | "
                          f"{s.get('title') or '?':<35.35} | "
                          f"{s.get('album') or '?'}", flush=True)
                time.sleep(2)
        except KeyboardInterrupt:
            return 0

    # Default: show current status
    s = bt_media.media_status(args.mac, args.adapter)
    if args.json:
        print(json.dumps(s, indent=2))
        return 0
    if not s.get("connected"):
        print(f"No active media player on {args.mac}.")
        print(f"  Reason: {s.get('reason', '?')}")
        print(f"  Hint: open any music/media app on the phone first")
        return 1
    print(f"Player:    {s['name']} ({s['type']}/{s.get('subtype', '?')})")
    print(f"Status:    {s['status']}")
    if s.get("title"):
        print(f"Track:     {s['title']}")
        print(f"Artist:    {s.get('artist', '?')}")
        print(f"Album:     {s.get('album', '?')}")
        if s.get("duration_ms"):
            cur = s.get("position_ms", 0) // 1000
            tot = s["duration_ms"] // 1000
            print(f"Position:  {cur//60}:{cur%60:02d} / {tot//60}:{tot%60:02d}")
        if s.get("track_number"):
            print(f"Track #:   {s['track_number']}/{s.get('track_count', '?')}")
    print(f"Shuffle:   {s.get('shuffle', '?')}    Repeat: {s.get('repeat', '?')}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-modems.py
content: #!/usr/bin/env python3
"""bt-modems — List ofono modems (1 per HFP-connected phone).

Examples:
  bt-modems              # one-line summary per modem
  bt-modems --json
  bt-modems --watch      # follow modem-add/remove events
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_telephony as bt


def main() -> int:
    ap = argparse.ArgumentParser(description="List ofono modems")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--full", action="store_true",
                    help="Per-modem full state (calls, network, signal)")
    args = ap.parse_args()

    try:
        modems = bt.list_modems()
    except Exception as e:
        sys.stderr.write(f"error: {e}\n")
        sys.stderr.write("(if 'Access denied': /etc/dbus-1/system.d/ofono-${USER}.conf may be missing)\n")
        return 2

    if args.full:
        modems = [bt.modem_state(m["path"]) for m in modems]

    if args.json:
        print(json.dumps(modems, indent=2, default=str))
        return 0

    if not modems:
        print("No ofono modems present. Pair a phone with HFP profile first.")
        print("Steps: bt-pair <phone-mac> --connect; phone needs HFP-AG profile.")
        return 0

    for m in modems:
        path = m["path"]
        powered = m.get("Powered", False)
        online = m.get("Online", False)
        name = m.get("Name", "")
        print(f"{path}")
        print(f"  Name:     {name}")
        print(f"  Powered:  {powered}   Online: {online}")
        ifaces = m.get("Interfaces", [])
        if ifaces:
            print(f"  Interfaces: {', '.join(ifaces)}")
        if "calls" in m:
            print(f"  Calls:    {len(m['calls'])} active")
            for c in m["calls"]:
                print(f"    - {c.get('LineIdentification', '?')} state={c.get('State', '?')}")
        if "network" in m and m["network"]:
            n = m["network"]
            print(f"  Network:  {n.get('Name', '?')} status={n.get('Status', '?')} "
                  f"strength={n.get('Strength', '?')}%")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-pair.py
content: #!/usr/bin/env python3
"""bt-pair — Pair (and optionally connect+trust) a discovered device.

The bt-agent.service must already be running (or start it with `systemctl --user start bt-agent`).

Examples:
  bt-pair AA:BB:CC:DD:EE:FF                 # pair, do NOT connect
  bt-pair AA:BB:CC:DD:EE:FF --connect       # pair + trust + connect
  bt-pair AA:BB:CC:DD:EE:FF --pin           # interactive PIN mode (requires agent in pin mode)
  bt-pair --discoverable                    # put OUR adapter in pairable mode (180s) for the
                                            # phone to initiate; useful for "pair my phone"
"""
from __future__ import annotations
import argparse, sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bt_lib import (device_op, set_trusted, get_device, adapter_path,
                    adapter_health, ADAPTER_IFACE, PROPS_IFACE,
                    BLUEZ_SERVICE, get_bus, AdapterDownError)
import dbus


def make_pairable(adapter: str, seconds: int = 180) -> None:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise AdapterDownError(f"adapter {adapter!r} not found")
    aobj = bus.get_object(BLUEZ_SERVICE, apath)
    props = dbus.Interface(aobj, PROPS_IFACE)
    props.Set(ADAPTER_IFACE, "Pairable", dbus.Boolean(True))
    props.Set(ADAPTER_IFACE, "PairableTimeout", dbus.UInt32(seconds))
    props.Set(ADAPTER_IFACE, "Discoverable", dbus.Boolean(True))
    props.Set(ADAPTER_IFACE, "DiscoverableTimeout", dbus.UInt32(seconds))


def main() -> int:
    ap = argparse.ArgumentParser(description="Pair a Bluetooth device")
    ap.add_argument("mac", nargs="?", help="MAC of device to pair (omit with --discoverable)")
    ap.add_argument("--adapter", default="hci0")
    ap.add_argument("--connect", action="store_true",
                    help="After pairing, trust + connect")
    ap.add_argument("--no-trust", action="store_true",
                    help="Do not set Trusted=true after pairing")
    ap.add_argument("--discoverable", action="store_true",
                    help="Just make our adapter pairable+discoverable (180s) and exit")
    ap.add_argument("--seconds", type=int, default=180,
                    help="--discoverable timeout (default 180)")
    ap.add_argument("--timeout", type=float, default=30.0,
                    help="Pair timeout in seconds (default 30)")
    args = ap.parse_args()

    health = adapter_health(args.adapter)
    if not health["ok"]:
        sys.stderr.write(f"adapter unhealthy: {health['reason']}\n")
        return 2

    if args.discoverable:
        make_pairable(args.adapter, args.seconds)
        print(f"{args.adapter}: Pairable+Discoverable for {args.seconds}s. "
              f"BD address {health['address']}, name {health['alias']!r}.")
        print("Now go to your phone's Bluetooth settings and pick that name.")
        return 0

    if not args.mac:
        sys.stderr.write("error: MAC required (or use --discoverable)\n")
        return 2

    print(f"Pairing {args.mac} on {args.adapter}...")
    try:
        device_op(args.mac, "Pair", adapter=args.adapter, timeout=args.timeout)
    except RuntimeError as e:
        sys.stderr.write(f"PAIR FAILED: {e}\n")
        return 1

    # Verify
    info = get_device(args.mac, args.adapter)
    if not info.get("paired"):
        sys.stderr.write("Pair returned but device is not Paired in BlueZ — strange\n")
        return 1
    print(f"  PAIRED OK ({info.get('name') or args.mac})")

    if not args.no_trust:
        set_trusted(args.mac, True, adapter=args.adapter)
        print("  Trusted=true")

    if args.connect:
        print(f"  connecting...")
        try:
            device_op(args.mac, "Connect", adapter=args.adapter, timeout=args.timeout)
        except RuntimeError as e:
            sys.stderr.write(f"CONNECT FAILED: {e}\n")
            return 1
        info = get_device(args.mac, args.adapter)
        print(f"  CONNECTED ({'profiles negotiated' if info.get('connected') else 'connection state ambiguous'})")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-pan.py
content: #!/usr/bin/env python3
"""bt-pan — Use a paired phone as an internet uplink via Bluetooth PAN.

When the phone advertises NAP (0x1116 = Network Access Point), we can
connect to it as a PANU (User) and the kernel creates a bnep0 network
interface that we can DHCP on.

Usage:
  bt-pan up MAC                   # connect, bring up bnep0, run DHCP
  bt-pan down MAC                 # disconnect PAN, deconfigure interface
  bt-pan status                   # show current state

Phone-side prerequisite (one-time):
  - Phone must have BT-tethering enabled
  - Settings → Connections → Mobile Hotspot and Tethering → Bluetooth tethering ON
"""
from __future__ import annotations
import argparse, os, subprocess, sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))

import dbus
from bt_lib import (BLUEZ_SERVICE, get_bus, adapter_path, _mac_to_device_path,
                    DEVICE_IFACE)

NETWORK_IFACE = "org.bluez.Network1"


def _sudo(*args: str) -> tuple[int, str]:
    """Run a command via sudo.

    Reads password from SUDO_PASS env if set; otherwise relies on sudo's
    own prompt or a passwordless-sudo rule. NEVER hardcodes a password.

    Recommended: configure passwordless sudo for ip + dhclient, e.g.:
      <youruser> ALL=(root) NOPASSWD: /usr/sbin/ip, /usr/sbin/dhclient
    """
    pw = os.environ.get("SUDO_PASS")
    cmd = ["sudo", "-S" if pw else "-n", *args]
    stdin_input = (pw + "\n") if pw else None
    p = subprocess.run(cmd, input=stdin_input, capture_output=True, text=True)
    return p.returncode, (p.stdout + p.stderr).strip()


def pan_up(mac: str, adapter: str = "hci0") -> dict:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise RuntimeError(f"adapter {adapter!r} not found")
    dpath = _mac_to_device_path(apath, mac)
    dobj = bus.get_object(BLUEZ_SERVICE, dpath)
    net = dbus.Interface(dobj, NETWORK_IFACE)
    iface = str(net.Connect("nap"))   # "nap" = Network Access Point profile
    return {"interface": iface}


def pan_down(mac: str, adapter: str = "hci0") -> None:
    bus = get_bus()
    apath = adapter_path(adapter)
    dpath = _mac_to_device_path(apath, mac)
    dobj = bus.get_object(BLUEZ_SERVICE, dpath)
    net = dbus.Interface(dobj, NETWORK_IFACE)
    net.Disconnect()


def main() -> int:
    ap = argparse.ArgumentParser(description="BT PAN — phone-as-internet-uplink")
    ap.add_argument("op", choices=["up", "down", "status"])
    ap.add_argument("mac", nargs="?")
    ap.add_argument("--adapter", default="hci0")
    args = ap.parse_args()

    if args.op == "status":
        rc, out = _sudo("ip", "link", "show", "type", "bridge_slave")
        # bnep is not a bridge; try direct
        rc, out = _sudo("ip", "-br", "link", "show")
        for line in out.splitlines():
            if line.startswith("bnep"):
                print(line)
        # Also show route table
        rc, out = _sudo("ip", "-br", "route")
        print("\n--- Routes ---")
        for line in out.splitlines():
            if "bnep" in line or "default" in line:
                print(line)
        return 0

    if not args.mac:
        sys.stderr.write("MAC required for up/down\n")
        return 2

    if args.op == "up":
        try:
            r = pan_up(args.mac, args.adapter)
        except Exception as e:
            sys.stderr.write(f"PAN connect failed: {e}\n"
                             f"  Hint: ensure 'Bluetooth tethering' is ON on the phone.\n")
            return 1
        iface = r["interface"]
        print(f"connected, interface = {iface}")

        # Bring up + DHCP
        time.sleep(1)
        _sudo("ip", "link", "set", iface, "up")
        rc, out = _sudo("dhclient", "-v", iface)
        print(out[-500:] if len(out) > 500 else out)

        # Show resulting IP
        rc, out = _sudo("ip", "-br", "addr", "show", iface)
        print(out)
        return 0

    if args.op == "down":
        # Release DHCP
        _sudo("dhclient", "-r", "bnep0")
        try:
            pan_down(args.mac, args.adapter)
        except Exception as e:
            sys.stderr.write(f"PAN disconnect failed: {e}\n")
            return 1
        print("disconnected")
        return 0
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-play.py
content: #!/usr/bin/env python3
"""bt-play — Play an audio file, optionally to a specific BT speaker.

Examples:
  bt-play /usr/share/sounds/alsa/Front_Center.wav            # plays to default sink
  bt-play song.mp3 --sink AA:BB:CC:DD:EE:FF                  # routes to a paired BT speaker
  bt-play song.mp3 --bg                                      # background, returns immediately

If --sink MAC is given, the device must be paired+connected with the A2DP-sink
profile active. We look up its PipeWire node via the MAC fragment and use
pw-play --target.
"""
from __future__ import annotations
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_audio


def main() -> int:
    ap = argparse.ArgumentParser(description="Play an audio file")
    ap.add_argument("file")
    ap.add_argument("--sink", help="Route to BT MAC's PipeWire sink")
    ap.add_argument("--sink-id", type=int, help="Route to numeric wpctl object id")
    ap.add_argument("--bg", action="store_true", help="Background; return immediately")
    args = ap.parse_args()

    target_id = args.sink_id
    if args.sink:
        s = bt_audio.find_bluez_sink(args.sink)
        if s is None:
            sys.stderr.write(f"error: no PipeWire sink found for {args.sink}. "
                             f"Run `bt-info {args.sink}` and confirm Connected=True with A2DP profile.\n")
            return 1
        target_id = s["id"]
        sys.stderr.write(f"[bt-play] routing to {s['node_description']} (id={s['id']})\n")

    fp = Path(args.file).expanduser().resolve()
    if not fp.exists():
        sys.stderr.write(f"error: file not found: {fp}\n")
        return 1

    if args.bg:
        proc = bt_audio.play_file(str(fp), target_id=target_id, blocking=False)
        print(f"playing in background, pid={proc.pid}")
        return 0
    rc = bt_audio.play_file(str(fp), target_id=target_id, blocking=True)
    return rc


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-receive.py
content: #!/usr/bin/env python3
"""bt-receive — Listen for OBEX pushes from paired devices.

Foreground: registers an OBEX agent and saves any incoming files to
~/Downloads/bluetooth/ (or --save-to). Ctrl-C to stop.

Examples:
  bt-receive
  bt-receive --save-to /tmp/incoming
"""
from __future__ import annotations
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_obex


def main() -> int:
    ap = argparse.ArgumentParser(description="Listen for incoming OBEX pushes")
    ap.add_argument("--save-to", default=None,
                    help="Where to save received files (default ~/Downloads/bluetooth)")
    args = ap.parse_args()

    return bt_obex.run_receive_agent(save_dir=args.save_to)


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-recover.py
content: #!/usr/bin/env python3
"""bt-recover — Diagnose and recover a BlueZ adapter that's stuck.

Common modes of failure on the BCM43142 (and many cheap chipsets):
  * BD address shows 00:00:00:00:00:00 — firmware lost
  * "Can't init device hciN: Connection timed out" — controller wedged

This script:
  1. Reports the current state of every BlueZ adapter
  2. Runs `rfkill unblock bluetooth` and `systemctl restart bluetooth`
  3. If that doesn't recover the adapter (BD still all-zero or DOWN), it
     finds the matching USB device by VID:PID and de/re-authorises it,
     which forces the kernel to re-attach the firmware.
  4. Re-checks and reports.

Requires sudo. Either:
  * configure passwordless sudo for the commands this script runs (recommended):
      visudo -f /etc/sudoers.d/paired-bt-recover
      <youruser> ALL=(root) NOPASSWD: /usr/sbin/rfkill, /usr/bin/systemctl, /usr/sbin/hciconfig, /usr/bin/sh
  * or set SUDO_PASS in the environment before invoking (insecure on shared hosts)
  * or run interactively and let sudo prompt
"""
from __future__ import annotations
import argparse, os, subprocess, sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bt_lib import list_adapters, adapter_health


def _sudo(*args: str) -> tuple[int, str]:
    """Run a command via sudo.

    Reads password from SUDO_PASS env if set; otherwise relies on sudo's
    own prompt or a passwordless-sudo rule. NEVER hardcodes a password.
    """
    pw = os.environ.get("SUDO_PASS")
    cmd = ["sudo", "-S" if pw else "-n", *args]
    stdin_input = (pw + "\n") if pw else None
    p = subprocess.run(cmd, input=stdin_input, capture_output=True, text=True)
    return p.returncode, (p.stdout + p.stderr).strip()


def _find_usb_bt() -> list[tuple[str, str, str]]:
    """Return list of (sysfs_dir, vendor, product) for USB BT controllers."""
    out = []
    for d in Path("/sys/bus/usb/devices").iterdir():
        v = d / "idVendor"
        p = d / "idProduct"
        if not v.exists() or not p.exists():
            continue
        # Only USB BT classes have a btusb-bound interface
        # Skip hubs etc.
        try:
            vid = v.read_text().strip()
            pid = p.read_text().strip()
        except OSError:
            continue
        # Look for an interface bound to btusb
        for sub in d.iterdir():
            if sub.name.startswith(d.name + ":"):
                drv = sub / "driver"
                if drv.exists() and "btusb" in os.readlink(drv):
                    out.append((str(d), vid, pid))
                    break
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description="Recover a stuck BlueZ adapter")
    ap.add_argument("--adapter", default="hci0")
    ap.add_argument("--force-usb-reset", action="store_true",
                    help="Skip the soft attempts and reset the USB device immediately")
    args = ap.parse_args()

    print(f"=== Initial state ===")
    for a in list_adapters():
        print(f"  {a['name']}: {a['address']} powered={a['powered']}")

    h0 = adapter_health(args.adapter)
    if h0["ok"] and not args.force_usb_reset:
        print(f"\n{args.adapter} looks healthy — no recovery needed.")
        return 0
    print(f"\n{args.adapter} unhealthy: {h0['reason']}")

    if not args.force_usb_reset:
        print("\n=== Soft recovery: rfkill + bluetooth restart ===")
        _sudo("rfkill", "unblock", "bluetooth")
        _sudo("systemctl", "restart", "bluetooth")
        time.sleep(3)
        _sudo("hciconfig", args.adapter, "up")
        time.sleep(2)
        h1 = adapter_health(args.adapter)
        if h1["ok"]:
            print(f"  Recovered: {h1['address']}")
            return 0
        print(f"  Soft recovery insufficient: {h1['reason']}")

    print("\n=== Hard recovery: USB de/re-authorise ===")
    btusb_devs = _find_usb_bt()
    if not btusb_devs:
        print("  No USB BT device found — aborting (built-in non-USB controller?)")
        return 1
    for sysdir, vid, pid in btusb_devs:
        dev = Path(sysdir).name
        print(f"  Resetting USB device {dev} (vid={vid} pid={pid})")
        _sudo("sh", "-c", f"echo 0 > {sysdir}/authorized")
        time.sleep(1)
        _sudo("sh", "-c", f"echo 1 > {sysdir}/authorized")
        time.sleep(4)

    _sudo("systemctl", "restart", "bluetooth")
    time.sleep(3)
    _sudo("hciconfig", args.adapter, "up")
    time.sleep(2)

    h2 = adapter_health(args.adapter)
    if h2["ok"]:
        print(f"\nRecovered: {h2['address']} powered={h2['powered']}")
        return 0
    print(f"\nStill unhealthy: {h2['reason']}")
    return 1


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-send.py
content: #!/usr/bin/env python3
"""bt-send — OBEX Object Push: push a file to a paired BT device.

Examples:
  bt-send ~/Downloads/photo.jpg AA:BB:CC:DD:EE:FF
  bt-send report.pdf AA:BB:CC:DD:EE:FF --timeout 300

The destination device must be paired + Object Push capable. On phones,
you'll get a notification asking to accept the incoming file. The
device must accept it within --timeout seconds (default 120).
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_obex


def main() -> int:
    ap = argparse.ArgumentParser(description="OBEX Object Push to a paired device")
    ap.add_argument("file")
    ap.add_argument("mac")
    ap.add_argument("--timeout", type=float, default=120.0)
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    fp = Path(args.file).expanduser().resolve()
    if not fp.exists():
        sys.stderr.write(f"error: {fp} does not exist\n")
        return 1

    print(f"Pushing {fp} ({fp.stat().st_size} bytes) to {args.mac}...")
    print("On the receiving device, accept the incoming file when prompted.")
    try:
        result = bt_obex.push_file(args.mac, str(fp), timeout=args.timeout)
    except Exception as e:
        sys.stderr.write(f"PUSH FAILED: {e}\n")
        return 1

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        status = result.get("transfer_status")
        if status == "complete":
            print(f"  COMPLETE — {result.get('size')} bytes sent")
            return 0
        else:
            print(f"  FINAL STATUS: {status}")
            return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-sms.py
content: #!/usr/bin/env python3
"""bt-sms — Send / list SMS through a paired phone.

Examples:
  bt-sms-send 07911123456 "running 5 mins late"
  bt-sms-send 07911123456 "hi" --modem AA:BB:..        # specific phone
  bt-sms-send 07911123456 "hi" --via-map               # use MAP not ofono
  bt-sms-list                                          # ofono outgoing queue
  bt-sms-list --map AA:BB:CC:DD:EE:FF                  # MAP inbox (first 25)
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_telephony as bt
import bt_obex_msg as obex_msg


def main() -> int:
    invoked = Path(sys.argv[0]).name
    op = "list" if "list" in invoked else "send"

    ap = argparse.ArgumentParser(description=f"SMS {op}")
    if op == "send":
        ap.add_argument("number")
        ap.add_argument("text")
        ap.add_argument("--modem", help="Phone MAC (default: first ofono modem)")
        ap.add_argument("--via-map", action="store_true",
                        help="Use OBEX MAP instead of ofono (rarely needed)")
    else:
        ap.add_argument("--modem", help="Phone MAC for ofono outgoing list")
        ap.add_argument("--map", dest="map_mac",
                        help="Show MAP inbox/outbox for this paired phone MAC")
        ap.add_argument("--folder", default="telecom/msg/inbox",
                        help="MAP folder, default telecom/msg/inbox")
        ap.add_argument("--max", type=int, default=25)
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    if op == "send":
        if args.via_map:
            if not args.modem:
                sys.stderr.write("--via-map needs --modem MAC of paired phone\n")
                return 2
            try:
                r = obex_msg.map_send_message(args.modem, args.number, args.text)
            except Exception as e:
                sys.stderr.write(f"MAP send failed: {e}\n")
                return 1
            (print(json.dumps(r, indent=2)) if args.json
             else print(f"MAP push: {r['status']}"))
            return 0 if r["status"] == "complete" else 1

        # ofono path
        modems = bt.list_modems()
        if not modems:
            sys.stderr.write("No ofono modem. Pair a phone with HFP first.\n")
            return 2
        modem = bt.modem_for_mac(args.modem) if args.modem else modems[0]
        if modem is None:
            sys.stderr.write(f"no ofono modem matches MAC {args.modem}\n")
            return 1
        ifaces = modem.get("Interfaces", [])
        if "org.ofono.MessageManager" not in ifaces:
            sys.stderr.write(
                f"This phone does not expose SMS via Bluetooth.\n"
                f"Modem interfaces present: {', '.join(ifaces) or '(none)'}\n"
                f"\nMost modern Samsung/iOS phones disable HFP MessageManager\n"
                f"and require MAP (Message Access Profile) which they don't expose\n"
                f"to non-Samsung-Account paired devices. SMS-send via BT is not possible\n"
                f"on this phone with default settings.\n"
            )
            return 3
        try:
            mpath = bt.sms_send(modem["path"], args.number, args.text)
        except Exception as e:
            sys.stderr.write(f"sms_send failed: {e}\n")
            return 1
        if args.json:
            print(json.dumps({"message_path": mpath, "modem": modem["path"]}, indent=2))
        else:
            print(f"sent: {mpath}")
        return 0

    # list path
    if args.map_mac:
        try:
            msgs = obex_msg.map_list_messages(args.map_mac,
                                               folder=args.folder,
                                               max_count=args.max)
        except Exception as e:
            sys.stderr.write(f"MAP list failed: {e}\n")
            return 1
        if args.json:
            print(json.dumps(msgs, indent=2))
            return 0
        if not msgs:
            print(f"(empty: {args.folder})")
            return 0
        for m in msgs:
            sender = m.get("Sender") or m.get("SenderAddress") or "?"
            ts = m.get("Timestamp", "?")
            subj = m.get("Subject") or "(no subject)"
            mtype = m.get("Type", "")
            read = " [READ]" if m.get("Read") == "1" else "      "
            print(f"  {ts}{read}  {sender:<20.20}  {mtype:<8}  {subj}")
        return 0

    # ofono outgoing queue
    modems = bt.list_modems()
    if not modems:
        sys.stderr.write("No ofono modem.\n")
        return 2
    modem = bt.modem_for_mac(args.modem) if args.modem else modems[0]
    ifaces = modem.get("Interfaces", [])
    if "org.ofono.MessageManager" not in ifaces:
        if args.json:
            print(json.dumps({"error": "modem has no MessageManager interface", "interfaces": ifaces}, indent=2))
        else:
            print(f"Modem {modem['path']} does not expose MessageManager.")
            print(f"Interfaces: {', '.join(ifaces) or '(none)'}")
        return 3
    msgs = bt.sms_list_outgoing(modem["path"])
    if args.json:
        print(json.dumps(msgs, indent=2))
    else:
        if not msgs:
            print("no pending outgoing messages")
        for m in msgs:
            print(f"  {m['path']}  state={m.get('State', '?')}  "
                  f"to={m.get('Recipient', '?')}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-test.py
content: #!/usr/bin/env python3
"""bt-test — Self-test the bluetooth skill stack.

Runs a series of read-only checks across BlueZ, the agent, PipeWire,
OBEX and ofono, prints a clean pass/fail report, and exits non-zero if
anything looks wrong. Safe to run any time — does not pair, connect,
write, transfer or send.

Use this when:
  - troubleshooting a flaky pair/connect
  - confirming after a reboot that the stack came back up
  - in a healthcheck cron
"""
from __future__ import annotations
import json, os, subprocess, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))


CHECKS = []


def check(name: str):
    def deco(fn):
        CHECKS.append((name, fn))
        return fn
    return deco


@check("BlueZ system bus reachable")
def chk_bluez():
    import bt_lib
    adapters = bt_lib.list_adapters()
    if not adapters:
        return False, "no adapters present"
    return True, f"{len(adapters)} adapter(s): {', '.join(a['name'] for a in adapters)}"


@check("All adapters healthy")
def chk_adapter_health():
    import bt_lib
    bad = []
    for a in bt_lib.list_adapters():
        h = bt_lib.adapter_health(a["name"])
        if not h["ok"]:
            bad.append(f"{a['name']}: {h['reason']}")
    if bad:
        return False, "; ".join(bad)
    return True, "all powered, BD addresses non-zero"


@check("Pairing agent (bt-agent.service) active")
def chk_agent():
    p = subprocess.run(["systemctl", "--user", "is-active", "bt-agent.service"],
                       capture_output=True, text=True)
    if p.stdout.strip() == "active":
        return True, "active"
    return False, p.stdout.strip() or "not active"


@check("PipeWire/WirePlumber active")
def chk_pipewire():
    units = ["pipewire.service", "pipewire-pulse.service", "wireplumber.service"]
    bad = []
    for u in units:
        p = subprocess.run(["systemctl", "--user", "is-active", u],
                           capture_output=True, text=True)
        if p.stdout.strip() != "active":
            bad.append(f"{u}: {p.stdout.strip()}")
    if bad:
        return False, "; ".join(bad)
    return True, "pipewire + pipewire-pulse + wireplumber all active"


@check("PipeWire BT codec plug-in present")
def chk_libspa():
    p = "/usr/lib/x86_64-linux-gnu/spa-0.2/bluez5/libspa-bluez5.so"
    if Path(p).exists():
        return True, "libspa-bluez5.so present"
    return False, "libspa-0.2-bluetooth not installed"


@check("OBEX (org.bluez.obex) on session bus")
def chk_obex():
    p = subprocess.run(["busctl", "--user", "list"], capture_output=True, text=True)
    if "org.bluez.obex" in p.stdout:
        return True, "registered (or activatable)"
    return False, "not registered/activatable"


@check("ofono active and accessible to current user")
def chk_ofono():
    p = subprocess.run(["systemctl", "is-active", "ofono"], capture_output=True, text=True)
    if p.stdout.strip() != "active":
        return False, f"ofono.service: {p.stdout.strip()}"
    # Make sure we can actually call it as user (not root)
    try:
        import bt_telephony
        modems = bt_telephony.list_modems()
        return True, f"{len(modems)} modem(s) currently registered"
    except Exception as e:
        return False, f"D-Bus access denied? {e}"


@check("All bt-* commands on PATH")
def chk_path():
    expected = [
        "bt-adapters", "bt-list", "bt-info", "bt-pair", "bt-connect",
        "bt-disconnect", "bt-trust", "bt-untrust", "bt-forget",
        "bt-gatt-read", "bt-gatt-write", "bt-gatt-tree", "bt-recover",
        "bt-audio", "bt-play", "bt-volume", "bt-send", "bt-receive",
        "bt-browse", "bt-modems", "bt-call", "bt-sms-send", "bt-sms-list",
        "bt-contacts", "bt-media", "bt-pan", "bt-battery",
        "bt-adb-setup", "bt-adb-sms-list", "bt-adb-sms-send",
        "bt-adb-push", "bt-adb-pull", "bt-adb-battery", "bt-adb-notif",
        "bt-adb-screenshot", "bt-adb-launch", "bt-adb-type", "bt-adb-media",
    ]
    bin_dir = Path.home() / "bin"
    missing = [c for c in expected if not (bin_dir / c).exists()]
    if missing:
        return False, f"missing: {', '.join(missing)}"
    return True, f"all {len(expected)} present"


@check("Shared libraries importable")
def chk_libs():
    try:
        import bt_lib, bt_audio, bt_obex, bt_telephony, bt_obex_msg, bt_adb, bt_media
        return True, "all 7 libraries import"
    except Exception as e:
        return False, f"{type(e).__name__}: {e}"



@check("ADB available + at least one device (optional)")
def chk_adb():
    import subprocess
    try:
        p = subprocess.run(["/usr/bin/adb", "devices"],
                            capture_output=True, text=True, timeout=5)
    except FileNotFoundError:
        return True, "adb not installed (skip — only needed for bt-adb-* tools)"
    except subprocess.TimeoutExpired:
        return False, "adb hung"
    devs = [l.split()[0] for l in p.stdout.splitlines()
            if l.strip() and not l.startswith("List of") and "device" in l]
    if not devs:
        return True, "adb installed but no devices (only needed for bt-adb-* tools)"
    return True, f"{len(devs)} device(s): {', '.join(devs)}"


def main() -> int:
    json_out = "--json" in sys.argv
    results = []
    pass_count = 0
    fail_count = 0
    for name, fn in CHECKS:
        try:
            ok, detail = fn()
        except Exception as e:
            ok, detail = False, f"{type(e).__name__}: {e}"
        results.append({"check": name, "ok": ok, "detail": detail})
        if ok:
            pass_count += 1
        else:
            fail_count += 1

    if json_out:
        print(json.dumps({"pass": pass_count, "fail": fail_count, "results": results}, indent=2))
        return 1 if fail_count else 0

    width = max(len(r["check"]) for r in results) + 2
    for r in results:
        sym = "OK  " if r["ok"] else "FAIL"
        print(f"  [{sym}]  {r['check']:<{width}}  {r['detail']}")
    print()
    print(f"{pass_count} passed, {fail_count} failed")
    return 1 if fail_count else 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-trust.py
content: #!/usr/bin/env python3
"""bt-trust — Mark / unmark a paired device as Trusted.

Trusted devices auto-authorise profile connects (a paired BT speaker can
auto-reconnect when it powers up, etc.).
"""
from __future__ import annotations
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bt_lib import set_trusted, get_device, adapter_health, remove_device


def main() -> int:
    invoked = Path(sys.argv[0]).name
    ap = argparse.ArgumentParser(description="Trust / untrust / forget a BT device")
    ap.add_argument("mac")
    ap.add_argument("--adapter", default="hci0")
    if "untrust" in invoked:
        op = "untrust"
    elif "forget" in invoked or "unpair" in invoked:
        op = "forget"
    else:
        op = "trust"
    args = ap.parse_args()

    health = adapter_health(args.adapter)
    if not health["ok"]:
        sys.stderr.write(f"adapter unhealthy: {health['reason']}\n")
        return 2

    try:
        d = get_device(args.mac, args.adapter)
    except RuntimeError as e:
        sys.stderr.write(f"error: {e}\n")
        return 1

    if op == "trust":
        set_trusted(args.mac, True, args.adapter)
        print(f"{args.mac}: Trusted=true")
    elif op == "untrust":
        set_trusted(args.mac, False, args.adapter)
        print(f"{args.mac}: Trusted=false")
    elif op == "forget":
        try:
            remove_device(args.mac, args.adapter)
        except RuntimeError as e:
            sys.stderr.write(f"error: {e}\n")
            return 1
        print(f"{args.mac}: removed (unpaired + forgotten)")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt-volume.py
content: #!/usr/bin/env python3
"""bt-volume — Get / set volume on a sink (BT or local).

Examples:
  bt-volume                                  # show default sink volume
  bt-volume --sink AA:BB:CC:DD:EE:FF         # show specific BT sink
  bt-volume --sink AA:BB:CC:DD:EE:FF 50      # set to 50%
  bt-volume --id 55 75                       # set sink id 55 to 75%
  bt-volume --mute --id 55                   # mute
"""
from __future__ import annotations
import argparse, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import bt_audio


def _resolve_target(args) -> int:
    """Return the wpctl object id to control."""
    if args.id is not None:
        return args.id
    if args.sink:
        s = bt_audio.find_bluez_sink(args.sink)
        if s is None:
            sys.stderr.write(f"error: no sink for {args.sink}\n")
            sys.exit(1)
        return s["id"]
    # Default: find the default sink
    sinks = bt_audio.list_sinks()
    default = next((s for s in sinks if s["default"]), None)
    if default is None:
        sys.stderr.write("error: no default sink and no --sink/--id given\n")
        sys.exit(1)
    return default["id"]


def main() -> int:
    ap = argparse.ArgumentParser(description="Get/set audio volume")
    ap.add_argument("level", nargs="?", type=int, help="0-150 percent (omit to read)")
    ap.add_argument("--sink", help="BT MAC of target sink")
    ap.add_argument("--id", type=int, help="numeric wpctl object id")
    ap.add_argument("--mute", action="store_true", help="mute (instead of setting level)")
    ap.add_argument("--unmute", action="store_true")
    args = ap.parse_args()

    target = _resolve_target(args)

    if args.mute:
        bt_audio.set_muted(target, True)
        print(f"id {target}: muted")
        return 0
    if args.unmute:
        bt_audio.set_muted(target, False)
        print(f"id {target}: unmuted")
        return 0

    if args.level is None:
        vol, muted = bt_audio.get_volume(target)
        print(f"id {target}: {vol*100:.0f}%{'  [MUTED]' if muted else ''}")
        return 0

    if not 0 <= args.level <= 150:
        sys.stderr.write("error: level must be 0-150\n")
        return 2
    bt_audio.set_volume_pct(target, args.level)
    print(f"id {target}: {args.level}%")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: bin/bt_adb.py
content: """
bt_adb.py — ADB companion library for the bluetooth skill.

Provides ADB-based fallback / complementary capabilities when the BT-native
path is blocked by phone-side restrictions (Samsung disabling OBEX OPP,
locking MAP-send, etc.) or when we want richer phone control than BT
profiles expose (notifications, screenshots, app launching, typing).

Architecture: every public function takes optional `serial=None` to pick
which adb device. If only one device is connected we use it; if multiple,
caller must specify.

Note: this lives on .86 alongside the BT tools. ADB binary at /usr/bin/adb
(installed via apt). Phone connects via USB cable directly to .86.
"""
from __future__ import annotations

import json
import re
import shlex
import subprocess
from pathlib import Path
from typing import Optional


ADB = "/usr/bin/adb"


class AdbError(RuntimeError):
    pass


def _adb(*args: str, serial: Optional[str] = None,
         timeout: float = 30.0,
         input_text: Optional[str] = None) -> str:
    """Run adb with optional -s serial and return stdout. Raises on failure."""
    cmd = [ADB]
    if serial:
        cmd += ["-s", serial]
    cmd += list(args)
    try:
        p = subprocess.run(cmd, capture_output=True, text=True,
                           timeout=timeout, input=input_text)
    except subprocess.TimeoutExpired:
        raise AdbError(f"adb {' '.join(args)} timed out after {timeout}s")
    if p.returncode != 0:
        raise AdbError(
            f"adb {' '.join(args)} exit={p.returncode}: "
            f"{p.stderr.strip() or p.stdout.strip()}"
        )
    return p.stdout


def list_devices() -> list[dict]:
    """Return [{serial, state, model?, manufacturer?}, ...]"""
    raw = _adb("devices", "-l")
    out = []
    for line in raw.splitlines():
        line = line.strip()
        if not line or line.startswith("List of devices"):
            continue
        parts = line.split()
        if len(parts) < 2:
            continue
        serial = parts[0]
        state = parts[1]
        # Parse the rest: "device usb:1-3 product:foo model:SM-N960F device:... transport_id:..."
        d = {"serial": serial, "state": state}
        for tok in parts[2:]:
            if ":" in tok:
                k, _, v = tok.partition(":")
                d[k] = v
        out.append(d)
    return out


def find_device(serial: Optional[str] = None) -> dict:
    """Return the chosen device dict. If serial omitted and exactly one device, picks it."""
    devs = [d for d in list_devices() if d.get("state") == "device"]
    if not devs:
        raise AdbError("No ADB devices in 'device' state. Plug phone in via USB and accept debug prompt.")
    if serial:
        for d in devs:
            if d["serial"] == serial:
                return d
        raise AdbError(f"Device with serial {serial!r} not found among: {[d['serial'] for d in devs]}")
    if len(devs) > 1:
        raise AdbError(
            f"Multiple ADB devices ({len(devs)}). Pass --serial to disambiguate. "
            f"Devices: {[d['serial'] for d in devs]}"
        )
    return devs[0]


def shell(cmd: str, *, serial: Optional[str] = None,
          timeout: float = 30.0) -> str:
    """Run a shell command on the device."""
    d = find_device(serial)
    return _adb("shell", cmd, serial=d["serial"], timeout=timeout)


def push(local: str, remote: str, *, serial: Optional[str] = None,
         timeout: float = 120.0) -> dict:
    """Push a local file to the phone."""
    local = str(Path(local).expanduser().resolve())
    if not Path(local).exists():
        raise FileNotFoundError(local)
    d = find_device(serial)
    out = _adb("push", local, remote, serial=d["serial"], timeout=timeout)
    return {"local": local, "remote": remote, "stdout": out.strip()}


def pull(remote: str, local: str, *, serial: Optional[str] = None,
         timeout: float = 120.0) -> dict:
    """Pull a remote file to local disk."""
    local = str(Path(local).expanduser().resolve())
    Path(local).parent.mkdir(parents=True, exist_ok=True)
    d = find_device(serial)
    out = _adb("pull", remote, local, serial=d["serial"], timeout=timeout)
    return {"remote": remote, "local": local, "stdout": out.strip()}


# ---------------------------------------------------------------------------
# Phone identity / state
# ---------------------------------------------------------------------------
def device_info(serial: Optional[str] = None) -> dict:
    d = find_device(serial)
    s = d["serial"]
    props = {
        "serial": s,
        "model": _adb("shell", "getprop ro.product.model", serial=s).strip(),
        "manufacturer": _adb("shell", "getprop ro.product.manufacturer", serial=s).strip(),
        "android_version": _adb("shell", "getprop ro.build.version.release", serial=s).strip(),
        "build": _adb("shell", "getprop ro.build.display.id", serial=s).strip(),
        "state": d.get("state"),
    }
    return props


def battery(serial: Optional[str] = None) -> dict:
    """Return battery state via dumpsys."""
    raw = shell("dumpsys battery", serial=serial)
    out: dict = {}
    for line in raw.splitlines():
        line = line.strip()
        if ": " in line:
            k, _, v = line.partition(": ")
            out[k.strip()] = v.strip()
    # Normalise key fields
    return {
        "level_pct": int(out.get("level", -1)) if out.get("level", "").lstrip("-").isdigit() else None,
        "scale": int(out.get("scale", 100)) if out.get("scale", "").isdigit() else 100,
        "voltage_mv": int(out["voltage"]) if out.get("voltage", "").isdigit() else None,
        "temperature_c": (int(out["temperature"]) / 10.0) if out.get("temperature", "").isdigit() else None,
        "ac_powered": out.get("AC powered") == "true",
        "usb_powered": out.get("USB powered") == "true",
        "wireless_powered": out.get("Wireless powered") == "true",
        "technology": out.get("technology"),
        "health": out.get("health"),
        "status_code": out.get("status"),
        "raw": out,
    }


# ---------------------------------------------------------------------------
# SMS  (the big one — works around the Samsung MAP-send block)
# ---------------------------------------------------------------------------
_SMS_PROJ = "_id,date,address,body,read,type"


def _parse_content_query(text: str) -> list[dict]:
    """Parse adb 'content query' Row output."""
    out = []
    for line in text.splitlines():
        m = re.match(r"^Row:\s*\d+\s+(.*)$", line)
        if not m:
            continue
        parts = m.group(1)
        d: dict = {}
        # Each part is key=value, but values can contain commas/spaces.
        # Use a simple state machine.
        i = 0
        while i < len(parts):
            eq = parts.find("=", i)
            if eq < 0:
                break
            key = parts[i:eq]
            # find the next ", key=" pattern; key is alphanumeric+_
            rest = parts[eq + 1:]
            nxt = re.search(r",\s+[A-Za-z_][A-Za-z0-9_]*=", rest)
            if nxt:
                value = rest[:nxt.start()]
                i = eq + 1 + nxt.start() + 2  # skip ", "
            else:
                value = rest
                i = len(parts)
            d[key.strip()] = value
        if d:
            out.append(d)
    return out


def sms_inbox(limit: int = 20, serial: Optional[str] = None) -> list[dict]:
    """Read recent inbox SMS via content provider."""
    cmd = (f'content query --uri content://sms/inbox '
           f'--projection "{_SMS_PROJ}" --sort "date DESC"')
    raw = shell(cmd, serial=serial)
    msgs = _parse_content_query(raw)[:limit]
    # Add iso timestamp for readability
    import datetime
    for m in msgs:
        try:
            ts = int(m.get("date", "0")) / 1000
            m["timestamp"] = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
        except (ValueError, TypeError):
            pass
    return msgs


def sms_sent(limit: int = 20, serial: Optional[str] = None) -> list[dict]:
    cmd = (f'content query --uri content://sms/sent '
           f'--projection "{_SMS_PROJ}" --sort "date DESC"')
    raw = shell(cmd, serial=serial)
    return _parse_content_query(raw)[:limit]


def sms_send(number: str, text: str, serial: Optional[str] = None) -> dict:
    """Send an SMS via the default messaging app's Intent.

    Uses am start -a android.intent.action.SENDTO with sms: scheme. This
    opens the messaging app pre-populated; we then use the keycode for
    KEYCODE_DPAD_CENTER (or the send button) — but that's UI-fragile.

    Better approach: insert directly into content://sms via content provider,
    BUT that only works if the running app is the default SMS app, which we
    aren't.

    Most reliable: use the `service call isms <code>` mechanism that calls
    SmsManager directly. Different Android versions number the codes
    differently. On Android 10+, sendTextMessage is method index 5.
    """
    # Use service call to ISms — this is the canonical SMS-send path
    # The exact transaction code varies by Android version but on N9 (Android 10)
    # SendTextMessageInternal is typically index 7 with a specific signature.
    # Simpler and more reliable: launch SMS app pre-populated, user taps send.

    # We'll do the simplest reliable thing: launch the messaging app with
    # the body pre-filled and the recipient set. User confirms by tapping send.
    # For headless agent use (no human interaction), see sms_send_silent.

    safe_body = text.replace('"', '\\"').replace("'", "\\'")
    cmd = (f'am start -a android.intent.action.SENDTO '
           f'-d "sms:{number}" '
           f'--es sms_body "{safe_body}" '
           f'--ez exit_on_sent true')
    out = shell(cmd, serial=serial)
    return {
        "method": "intent",
        "number": number,
        "text": text,
        "stdout": out.strip(),
        "note": "SMS app opened pre-populated; tap send on phone to deliver",
    }


def sms_send_silent(number: str, text: str,
                    serial: Optional[str] = None) -> dict:
    """Send SMS without UI by directly calling SmsManager via service call.

    REQUIRES: the device is rooted OR ADB has WRITE_SMS permission grant.
    Most user-mode Android 10+ devices reject this with SecurityException.
    """
    # Encode the strings as UTF-16 hex (Android Parcel format)
    def enc_str(s: str) -> str:
        # Each Parcel string is: int32 length + UTF-16-LE bytes + null term + padding
        # service call args use 's16' helper which we invoke via i32+s16
        return f's16:"{s}"'

    args = [enc_str(number),     # destAddr
            "null",              # scAddr
            enc_str(text),       # text
            "null",              # sentIntent
            "null"]              # deliveryIntent

    # Try a few common transaction codes. They differ across Android versions.
    for tx in (5, 7, 9, 4):
        cmd = f"service call isms {tx} {' '.join(args)}"
        try:
            out = shell(cmd, serial=serial, timeout=10.0)
            if "Result: Parcel" in out and "ffffffff" not in out[:80]:
                return {"method": f"service-call-tx{tx}", "result": out.strip()}
        except AdbError:
            continue
    raise AdbError(
        "service-call SMS send didn't succeed. The phone likely requires "
        "the calling app to be the default SMS handler. Use sms_send() (Intent UI) instead."
    )


# ---------------------------------------------------------------------------
# Notifications / UI / screen
# ---------------------------------------------------------------------------
def notifications(serial: Optional[str] = None) -> list[dict]:
    """List active notifications via dumpsys notification.

    Returns app, title, text fields per notification.
    """
    raw = shell("dumpsys notification --noredact", serial=serial,
                timeout=20.0)
    out: list[dict] = []
    cur: Optional[dict] = None
    for line in raw.splitlines():
        # Notification record header
        m = re.match(r'\s*NotificationRecord\(.*pkg=(\S+).*\bid=(\d+)', line)
        if m:
            if cur:
                out.append(cur)
            cur = {"package": m.group(1), "id": int(m.group(2))}
            continue
        if cur is None:
            continue
        # Look for title and text
        for field in ("android.title", "android.text", "android.subText"):
            tag = f"{field}=String "
            if tag in line:
                idx = line.find(tag)
                val = line[idx + len(tag):].strip().strip("()")
                cur[field.split(".")[-1]] = val
                break
    if cur:
        out.append(cur)
    return out


def screenshot(local_path: str = "~/Downloads/bluetooth/phone-screen.png",
               serial: Optional[str] = None) -> dict:
    """Capture phone screen and save to local file."""
    local_path = str(Path(local_path).expanduser().resolve())
    Path(local_path).parent.mkdir(parents=True, exist_ok=True)
    remote = "/sdcard/Pictures/_paired_capture.png"
    shell(f"screencap -p {remote}", serial=serial, timeout=20.0)
    pull(remote, local_path, serial=serial)
    shell(f"rm {remote}", serial=serial)
    return {"saved_to": local_path, "size_bytes": Path(local_path).stat().st_size}


def launch_app(package_or_action: str, serial: Optional[str] = None) -> dict:
    """Launch an app by package name (e.g. com.spotify.music) or full activity."""
    if "/" in package_or_action:
        cmd = f"am start -n {package_or_action}"
    else:
        cmd = f"monkey -p {package_or_action} -c android.intent.category.LAUNCHER 1"
    out = shell(cmd, serial=serial)
    return {"target": package_or_action, "stdout": out.strip()}


def type_text(text: str, serial: Optional[str] = None) -> dict:
    """Inject text into the focused field. Spaces become %s for adb."""
    safe = text.replace(" ", "%s").replace("'", "\\'")
    out = shell(f"input text '{safe}'", serial=serial)
    return {"text": text, "stdout": out.strip()}


# ---------------------------------------------------------------------------
# Media control via media-session (alternative to AVRCP)
# ---------------------------------------------------------------------------
def media_dispatch(action: str, serial: Optional[str] = None) -> dict:
    """Send a media key event. action ∈ {play,pause,play-pause,next,previous,stop}."""
    valid = {"play", "pause", "play-pause", "next", "previous", "stop"}
    if action not in valid:
        raise ValueError(f"action must be one of {valid}")
    out = shell(f"cmd media_session dispatch {action}", serial=serial)
    return {"action": action, "stdout": out.strip()}


def media_status(serial: Optional[str] = None) -> dict:
    """Get current media session state from MediaSessionService."""
    raw = shell("dumpsys media_session", serial=serial, timeout=10.0)
    # Parse out the "active sessions" block
    in_active = False
    sessions: list[dict] = []
    cur: dict = {}
    for line in raw.splitlines():
        if "Sessions Stack" in line and "size=" in line:
            in_active = True
            continue
        if in_active and line.strip().startswith("Media session record"):
            if cur:
                sessions.append(cur)
            cur = {"raw": line.strip()}
            m = re.search(r'pkg=(\S+)', line)
            if m:
                cur["package"] = m.group(1)
            continue
        if in_active and "state=PlaybackState" in line:
            m = re.search(r'state=PlaybackState \{state=(\d+)', line)
            if m:
                states = {0: "none", 1: "stopped", 2: "paused", 3: "playing",
                          4: "fast_forwarding", 5: "rewinding", 6: "buffering",
                          7: "error", 8: "connecting", 9: "skipping_to_previous",
                          10: "skipping_to_next", 11: "skipping_to_queue_item"}
                cur["state"] = states.get(int(m.group(1)), m.group(1))
        elif in_active and "metadata:" in line:
            m = re.search(r'description=([^,]+)', line)
            if m:
                cur["title"] = m.group(1).strip()
    if cur:
        sessions.append(cur)
    return {"sessions": sessions}


path: bin/bt_audio.py
content: """
bt_audio.py — Audio routing helpers for the OpenClaw `bluetooth` skill.

Talks to PipeWire/WirePlumber via wpctl + pw-cli to find Bluetooth audio
sinks and sources, set defaults, control volumes, and switch profiles.

Why subprocess wpctl/pw-cli rather than libpipewire bindings? Because
libpipewire C bindings via Python are heavy (gi or ctypes), and wpctl
is the canonical tool with a stable text format. Trade-off: a tiny bit
of parsing fragility against zero new dependencies.
"""
from __future__ import annotations

import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import Optional


def _wpctl(*args: str) -> str:
    """Run `wpctl <args>` and return stdout. Raises on failure."""
    p = subprocess.run(["wpctl", *args], capture_output=True, text=True)
    if p.returncode != 0:
        raise RuntimeError(
            f"wpctl {' '.join(args)} failed: {p.stderr.strip() or p.stdout.strip()}"
        )
    return p.stdout


def _pwcli(*args: str) -> str:
    p = subprocess.run(["pw-cli", *args], capture_output=True, text=True)
    if p.returncode != 0:
        raise RuntimeError(
            f"pw-cli {' '.join(args)} failed: {p.stderr.strip() or p.stdout.strip()}"
        )
    return p.stdout


def mac_to_bluez_name_fragment(mac: str) -> str:
    """AA:BB:CC:DD:EE:FF -> AA_BB_CC_DD_EE_FF  (PipeWire BT object naming)."""
    return mac.upper().replace(":", "_")


# ---------------------------------------------------------------------------
# Status / inventory
# ---------------------------------------------------------------------------
def list_sinks() -> list[dict]:
    """Parse `wpctl status` → list of {id, name, default, kind=sink}."""
    return _list_audio_objects("Sinks")


def list_sources() -> list[dict]:
    return _list_audio_objects("Sources")


def _list_audio_objects(section: str) -> list[dict]:
    """Generic parser. `section` is one of 'Sinks', 'Sources', 'Devices'."""
    out: list[dict] = []
    raw = _wpctl("status")
    in_audio = False
    in_section = False
    for line in raw.splitlines():
        stripped = line.strip()
        if stripped.startswith("Audio"):
            in_audio = True
            in_section = False
            continue
        if in_audio and stripped.startswith("Video"):
            break  # End of Audio block
        if not in_audio:
            continue
        if stripped.startswith(f"\u251c\u2500 {section}") or stripped.startswith(f"\u2514\u2500 {section}") \
                or stripped.startswith(f"├─ {section}") or stripped.startswith(f"└─ {section}"):
            in_section = True
            continue
        if in_section:
            # Format: "│      ID. Name [vol: 1.00]"  or  "│   *  ID. Name [vol: 1.00]"
            # When current default has a `*` prefix.
            m = re.match(r"[\s\u2502│]+(\*\s+)?([0-9]+)\.\s+(.+?)\s*(?:\[[^\]]*\])?\s*$", line)
            if m:
                is_default = bool(m.group(1))
                obj_id = int(m.group(2))
                name = m.group(3).strip()
                out.append({"id": obj_id, "name": name, "default": is_default})
            elif stripped.startswith("├─ ") or stripped.startswith("└─ "):
                in_section = False  # next sub-section
    return out


def find_bluez_sink(mac: str) -> Optional[dict]:
    """Return the wpctl sink dict for a paired+connected BT device, or None.

    PipeWire names BT sinks like
       bluez_output.AA_BB_CC_DD_EE_FF.a2dp-sink
    but `wpctl status` shows the friendly form (e.g. "JBL GO 4 (A2DP Sink)").
    We use `pw-cli list-objects Node` to get the bluez_output object path
    and then map to a wpctl id via the object.serial property.
    """
    frag = mac_to_bluez_name_fragment(mac)
    raw = _pwcli("list-objects", "Node")
    # pw-cli emits stanzas separated by blank lines; each has lines like
    #     id 84, type PipeWire:Interface:Node/3
    #         object.serial = "84"
    #         node.name = "bluez_output.AA_BB_CC_DD_EE_FF.a2dp-sink"
    #         media.class = "Audio/Sink"
    current: dict = {}
    nodes: list[dict] = []
    for line in raw.splitlines():
        if line.startswith("\tid ") or line.startswith("    id "):
            if current:
                nodes.append(current)
            m = re.search(r"id\s+(\d+)", line)
            current = {"id": int(m.group(1)) if m else None, "props": {}}
        elif "=" in line:
            k, _, v = line.strip().partition("=")
            v = v.strip().strip('"')
            current.setdefault("props", {})[k.strip()] = v
    if current:
        nodes.append(current)

    for n in nodes:
        node_name = n.get("props", {}).get("node.name", "")
        media_cls = n.get("props", {}).get("media.class", "")
        if frag in node_name and "Sink" in media_cls and "bluez_output" in node_name:
            return {
                "id": n["id"],
                "node_name": node_name,
                "device_api": n.get("props", {}).get("device.api"),
                "media_class": media_cls,
                "node_description": n.get("props", {}).get("node.description"),
            }
    return None


def find_bluez_source(mac: str) -> Optional[dict]:
    """Find a BT audio source (e.g. phone-as-mic via HFP, or A2DP source mode)."""
    frag = mac_to_bluez_name_fragment(mac)
    raw = _pwcli("list-objects", "Node")
    current: dict = {}
    nodes: list[dict] = []
    for line in raw.splitlines():
        if line.startswith("\tid ") or line.startswith("    id "):
            if current:
                nodes.append(current)
            m = re.search(r"id\s+(\d+)", line)
            current = {"id": int(m.group(1)) if m else None, "props": {}}
        elif "=" in line:
            k, _, v = line.strip().partition("=")
            v = v.strip().strip('"')
            current.setdefault("props", {})[k.strip()] = v
    if current:
        nodes.append(current)
    for n in nodes:
        node_name = n.get("props", {}).get("node.name", "")
        media_cls = n.get("props", {}).get("media.class", "")
        if frag in node_name and "Source" in media_cls and "bluez_input" in node_name:
            return {
                "id": n["id"],
                "node_name": node_name,
                "media_class": media_cls,
                "node_description": n.get("props", {}).get("node.description"),
            }
    return None


# ---------------------------------------------------------------------------
# Defaults / volume / mute
# ---------------------------------------------------------------------------
def set_default_sink(sink_id: int) -> None:
    _wpctl("set-default", str(sink_id))


def set_volume(obj_id: int, level: float) -> None:
    """Set volume 0.0–1.0+ (1.0 = 100 %, 1.5 = 150 % over-amplification)."""
    _wpctl("set-volume", str(obj_id), f"{level:.2f}")


def set_volume_pct(obj_id: int, pct: int) -> None:
    set_volume(obj_id, pct / 100.0)


def set_muted(obj_id: int, muted: bool) -> None:
    _wpctl("set-mute", str(obj_id), "1" if muted else "0")


def get_volume(obj_id: int) -> tuple[float, bool]:
    """Return (volume_0_to_1, muted)."""
    raw = _wpctl("get-volume", str(obj_id))
    # Format: "Volume: 0.85 [MUTED]"
    m = re.search(r"Volume:\s+([\d.]+)", raw)
    vol = float(m.group(1)) if m else 0.0
    muted = "MUTED" in raw
    return vol, muted


# ---------------------------------------------------------------------------
# Profiles (BT devices have multiple profiles: A2DP-sink, HFP-headunit, off)
# ---------------------------------------------------------------------------
def list_profiles_for_bt(mac: str) -> list[dict]:
    """Return the BT device's WirePlumber profiles. Requires the device to be paired+connected."""
    frag = mac_to_bluez_name_fragment(mac)
    # Find the device id (not node id) by walking pw-cli list-objects Device
    raw = _pwcli("list-objects", "Device")
    current: dict = {}
    devices: list[dict] = []
    for line in raw.splitlines():
        if line.startswith("\tid ") or line.startswith("    id "):
            if current:
                devices.append(current)
            m = re.search(r"id\s+(\d+)", line)
            current = {"id": int(m.group(1)) if m else None, "props": {}}
        elif "=" in line:
            k, _, v = line.strip().partition("=")
            v = v.strip().strip('"')
            current.setdefault("props", {})[k.strip()] = v
    if current:
        devices.append(current)

    target = next((d for d in devices if frag in d.get("props", {}).get("device.name", "")), None)
    if target is None:
        return []
    # Use wpctl inspect — profiles are listed under "* Spa:Pod:Object:Param:EnumProfile"
    raw = _wpctl("inspect", str(target["id"]))
    profiles: list[dict] = []
    cur: dict = {}
    in_enum = False
    for line in raw.splitlines():
        if "EnumProfile" in line:
            in_enum = True
            continue
        if not in_enum:
            continue
        if line.strip().startswith("Spa:Pod:Object:Param:Profile:"):
            if cur:
                profiles.append(cur)
            cur = {}
        m = re.match(r"\s+([\w.]+)\s*=\s*(.+)", line)
        if m:
            cur[m.group(1).strip()] = m.group(2).strip().strip('"')
    if cur:
        profiles.append(cur)
    return profiles


def set_bt_profile(mac: str, profile_index: int) -> None:
    """Switch a BT device to a different WirePlumber profile (e.g. A2DP -> HFP)."""
    frag = mac_to_bluez_name_fragment(mac)
    raw = _pwcli("list-objects", "Device")
    current: dict = {}
    devices: list[dict] = []
    for line in raw.splitlines():
        if line.startswith("\tid ") or line.startswith("    id "):
            if current:
                devices.append(current)
            m = re.search(r"id\s+(\d+)", line)
            current = {"id": int(m.group(1)) if m else None, "props": {}}
        elif "=" in line:
            k, _, v = line.strip().partition("=")
            v = v.strip().strip('"')
            current.setdefault("props", {})[k.strip()] = v
    if current:
        devices.append(current)

    target = next((d for d in devices if frag in d.get("props", {}).get("device.name", "")), None)
    if target is None:
        raise RuntimeError(f"BT device with MAC {mac} not present in WirePlumber. Connected?")
    _wpctl("set-profile", str(target["id"]), str(profile_index))


# ---------------------------------------------------------------------------
# Playback
# ---------------------------------------------------------------------------
def play_file(path: str, target_id: Optional[int] = None,
              blocking: bool = True) -> subprocess.Popen | int:
    """Play an audio file via pw-play.

    target_id: a sink object id. If None, uses the system default sink.
    blocking:  if True, run pw-play and wait; return exit code.
               if False, fork pw-play and return the Popen handle.
    """
    if not Path(path).exists():
        raise FileNotFoundError(path)
    cmd = ["pw-play"]
    if target_id is not None:
        cmd += ["--target", str(target_id)]
    cmd.append(path)
    if blocking:
        return subprocess.call(cmd)
    return subprocess.Popen(cmd)


def have_pipewire() -> bool:
    return shutil.which("wpctl") is not None and shutil.which("pw-cli") is not None


path: bin/bt_lib.py
content: """
bt_lib.py — Shared core for the OpenClaw `bluetooth` skill on .86.

Talks to BlueZ over D-Bus. Designed to scale: every public function takes an
explicit `adapter` arg so adding a second BT dongle is purely additive.
"""
from __future__ import annotations

import sys
import time
from typing import Iterable, Optional

try:
    import dbus
    import dbus.mainloop.glib
    from gi.repository import GLib
except ImportError as e:
    sys.stderr.write(f"bt_lib: missing system package: {e}\n")
    sys.stderr.write("Run: sudo apt install -y python3-dbus python3-gi\n")
    sys.exit(2)

BLUEZ_SERVICE = "org.bluez"
ADAPTER_IFACE = "org.bluez.Adapter1"
DEVICE_IFACE = "org.bluez.Device1"
GATT_SERVICE_IFACE = "org.bluez.GattService1"
GATT_CHAR_IFACE = "org.bluez.GattCharacteristic1"
GATT_DESC_IFACE = "org.bluez.GattDescriptor1"
AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
AGENT_IFACE = "org.bluez.Agent1"
PROPS_IFACE = "org.freedesktop.DBus.Properties"
OM_IFACE = "org.freedesktop.DBus.ObjectManager"

MANUFACTURERS = {
    6: "Microsoft", 13: "Texas Instruments", 15: "Broadcom", 76: "Apple",
    89: "Nordic Semiconductor", 117: "Samsung", 196: "LG Electronics",
    224: "Google", 301: "Logitech", 343: "Xiaomi", 637: "Polar Electro",
    647: "Withings", 742: "Fitbit", 1281: "Nordic Semiconductor",
    13825: "Bose", 11033: "Generic Beacon", 59761: "Sony", 65535: "Reserved",
}
REF_POWER_DBM = -55.0
ATTENUATION = 3.0


def manuf_name(mid):
    if mid is None:
        return None
    return MANUFACTURERS.get(mid, f"ID {mid}")


def addr_type(mac: str) -> str:
    try:
        first = int(mac.split(":")[0], 16)
    except (ValueError, IndexError):
        return "?"
    bits = first & 0xC0
    return {0x00: "PUB", 0x40: "RND-RESOLVABLE",
            0x80: "RND-NON-RESOLVABLE", 0xC0: "RND-STATIC"}.get(bits, "?")


def estimate_distance(rssi):
    if rssi is None or rssi == -127:
        return None
    try:
        return round(10 ** ((REF_POWER_DBM - rssi) / (10.0 * ATTENUATION)), 2)
    except (ValueError, OverflowError):
        return None


def _device_path_to_mac(path: str) -> Optional[str]:
    leaf = path.rsplit("/", 1)[-1]
    if not leaf.startswith("dev_"):
        return None
    return leaf[4:].replace("_", ":")


def _mac_to_device_path(adapter_path_str: str, mac: str) -> str:
    return f"{adapter_path_str}/dev_{mac.upper().replace(':', '_')}"


def get_bus():
    if not getattr(get_bus, "_set_default", False):
        dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
        get_bus._set_default = True
    return dbus.SystemBus()


def list_adapters() -> list[dict]:
    bus = get_bus()
    om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), OM_IFACE)
    out = []
    for path, ifaces in om.GetManagedObjects().items():
        if ADAPTER_IFACE not in ifaces:
            continue
        p = ifaces[ADAPTER_IFACE]
        out.append({
            "name": path.rsplit("/", 1)[-1],
            "path": str(path),
            "address": str(p.get("Address", "")),
            "alias": str(p.get("Alias", "")),
            "powered": bool(p.get("Powered", False)),
            "discoverable": bool(p.get("Discoverable", False)),
            "discovering": bool(p.get("Discovering", False)),
            "pairable": bool(p.get("Pairable", False)),
            "uuids": [str(u) for u in (p.get("UUIDs") or [])],
        })
    return out


def adapter_path(adapter: str = "hci0") -> Optional[str]:
    for a in list_adapters():
        if a["name"] == adapter:
            return a["path"]
    return None


class AdapterDownError(RuntimeError):
    pass


def adapter_health(adapter: str = "hci0") -> dict:
    info = {"adapter": adapter, "ok": False, "reason": None}
    found = next((a for a in list_adapters() if a["name"] == adapter), None)
    if not found:
        info["reason"] = f"adapter {adapter} not present in BlueZ"
        return info
    info.update(found)
    if found["address"] == "00:00:00:00:00:00":
        info["reason"] = "BD address all-zero — adapter firmware not loaded; USB reset needed"
        return info
    if not found["powered"]:
        info["reason"] = "adapter is powered down — bring up with `hciconfig hci0 up`"
        return info
    info["ok"] = True
    return info


def ensure_adapter_powered(adapter: str = "hci0") -> None:
    apath = adapter_path(adapter)
    if apath is None:
        raise AdapterDownError(f"adapter {adapter!r} not present")
    bus = get_bus()
    aobj = bus.get_object(BLUEZ_SERVICE, apath)
    props = dbus.Interface(aobj, PROPS_IFACE)
    addr = str(props.Get(ADAPTER_IFACE, "Address"))
    if addr == "00:00:00:00:00:00":
        raise AdapterDownError(
            f"adapter {adapter!r} has BD 00:00:00:00:00:00 — needs USB reset (run `bt-recover`)"
        )
    if not bool(props.Get(ADAPTER_IFACE, "Powered")):
        props.Set(ADAPTER_IFACE, "Powered", dbus.Boolean(True))


def _props_to_partial(props: dict) -> dict:
    out: dict = {}
    if "Address" in props:
        out["mac"] = str(props["Address"]).upper()
    if "Name" in props or "Alias" in props:
        n = props.get("Name") or props.get("Alias") or ""
        out["name_raw"] = str(n)
    if "RSSI" in props:
        try:
            out["rssi"] = int(props["RSSI"])
        except (TypeError, ValueError):
            out["rssi"] = None
    if "ManufacturerData" in props:
        md = props["ManufacturerData"] or {}
        try:
            out["manuf_id"] = int(next(iter(md.keys())))
        except (StopIteration, ValueError, TypeError):
            out["manuf_id"] = None
    if "UUIDs" in props:
        out["service_uuids"] = [str(u) for u in (props["UUIDs"] or [])]
    if "TxPower" in props:
        try:
            out["tx_power"] = int(props["TxPower"])
        except (TypeError, ValueError):
            out["tx_power"] = None
    if "Paired" in props:
        out["paired"] = bool(props["Paired"])
    if "Trusted" in props:
        out["trusted"] = bool(props["Trusted"])
    if "Connected" in props:
        out["connected"] = bool(props["Connected"])
    if "Class" in props:
        try:
            out["class"] = int(props["Class"])
        except (TypeError, ValueError):
            pass
    if "Icon" in props:
        out["icon"] = str(props["Icon"])
    return out


def _finalise(mac: str, partial: dict) -> dict:
    name = partial.get("name_raw", "") or ""
    if name.replace("-", ":").upper() == mac:
        name = ""
    rssi = partial.get("rssi")
    manuf_id = partial.get("manuf_id")
    return {
        "mac": mac, "name": name, "rssi": rssi,
        "manuf_id": manuf_id, "manuf_name": manuf_name(manuf_id),
        "addr_type": addr_type(mac),
        "distance_m": estimate_distance(rssi),
        "service_uuids": partial.get("service_uuids", []),
        "tx_power": partial.get("tx_power"),
        "paired": partial.get("paired", False),
        "trusted": partial.get("trusted", False),
        "connected": partial.get("connected", False),
        "icon": partial.get("icon"),
        "last_seen_unix": partial.get("last_seen_unix", time.time()),
    }


def scan(duration: float = 10.0, adapter: str = "hci0", transport: str = "le") -> list[dict]:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise AdapterDownError(f"BlueZ adapter {adapter!r} not found")
    ensure_adapter_powered(adapter)

    aobj = bus.get_object(BLUEZ_SERVICE, apath)
    aiface = dbus.Interface(aobj, ADAPTER_IFACE)
    try:
        aiface.SetDiscoveryFilter({"Transport": transport, "DuplicateData": False})
    except dbus.DBusException:
        pass

    accum: dict[str, dict] = {}

    def _ingest(mac: str, partial: dict) -> None:
        if not mac:
            return
        cur = accum.setdefault(mac, {"mac": mac, "last_seen_unix": time.time()})
        cur.update(partial)
        cur["last_seen_unix"] = time.time()

    def _on_iface_added(path, ifaces):
        if DEVICE_IFACE not in ifaces:
            return
        partial = _props_to_partial(ifaces[DEVICE_IFACE])
        mac = partial.get("mac") or _device_path_to_mac(path)
        if mac:
            _ingest(mac, partial)

    def _on_props_changed(iface, changed, invalidated, path=None):
        if iface != DEVICE_IFACE:
            return
        partial = _props_to_partial(changed)
        mac = partial.get("mac") or _device_path_to_mac(path or "")
        if mac:
            _ingest(mac, partial)

    om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), OM_IFACE)
    sig1 = om.connect_to_signal("InterfacesAdded", _on_iface_added)
    sig2 = bus.add_signal_receiver(_on_props_changed,
                                    signal_name="PropertiesChanged",
                                    dbus_interface=PROPS_IFACE,
                                    path_keyword="path")

    for path, ifaces in om.GetManagedObjects().items():
        if not path.startswith(apath + "/") or DEVICE_IFACE not in ifaces:
            continue
        partial = _props_to_partial(ifaces[DEVICE_IFACE])
        mac = partial.get("mac") or _device_path_to_mac(path)
        if mac:
            _ingest(mac, partial)

    aiface.StartDiscovery()
    try:
        loop = GLib.MainLoop()
        GLib.timeout_add_seconds(int(duration), loop.quit)
        loop.run()
    finally:
        try:
            aiface.StopDiscovery()
        except dbus.DBusException:
            pass
        sig1.remove()
        try:
            sig2.remove()
        except Exception:
            pass

    return [_finalise(mac, p) for mac, p in accum.items()]


def filter_devices(devs: Iterable[dict], *, named_only=False, min_rssi=None,
                   manuf=None, addr_type_filter=None,
                   paired_only=False, connected_only=False) -> list[dict]:
    out = list(devs)
    if named_only:
        out = [d for d in out if d.get("name")]
    if min_rssi is not None:
        out = [d for d in out if (d.get("rssi") or -127) >= min_rssi]
    if manuf:
        ml = manuf.lower()
        out = [d for d in out if (d.get("manuf_name") or "").lower() == ml]
    if addr_type_filter:
        out = [d for d in out if d.get("addr_type") == addr_type_filter]
    if paired_only:
        out = [d for d in out if d.get("paired")]
    if connected_only:
        out = [d for d in out if d.get("connected")]
    out.sort(key=lambda d: -(d.get("rssi") or -127))
    return out


def get_device(mac: str, adapter: str = "hci0") -> dict:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise AdapterDownError(f"adapter {adapter!r} not found")
    dpath = _mac_to_device_path(apath, mac)
    try:
        dobj = bus.get_object(BLUEZ_SERVICE, dpath)
        props = dbus.Interface(dobj, PROPS_IFACE)
        all_props = props.GetAll(DEVICE_IFACE)
    except dbus.DBusException as e:
        raise RuntimeError(
            f"BlueZ does not know device {mac!r} on {adapter!r}. "
            f"Run a scan first or check the address. ({e.get_dbus_name()})"
        ) from e
    return _finalise(mac.upper(), _props_to_partial(all_props))


def device_op(mac: str, op: str, adapter: str = "hci0", timeout: float = 30.0) -> None:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise AdapterDownError(f"adapter {adapter!r} not found")
    dpath = _mac_to_device_path(apath, mac)
    dobj = bus.get_object(BLUEZ_SERVICE, dpath)
    diface = dbus.Interface(dobj, DEVICE_IFACE)
    method = getattr(diface, op)
    try:
        method(timeout=int(timeout * 1000))
    except dbus.DBusException as e:
        raise RuntimeError(
            f"{op} {mac} on {adapter} failed: {e.get_dbus_message()}"
        ) from e


def set_trusted(mac: str, trusted: bool, adapter: str = "hci0") -> None:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise AdapterDownError(f"adapter {adapter!r} not found")
    dpath = _mac_to_device_path(apath, mac)
    dobj = bus.get_object(BLUEZ_SERVICE, dpath)
    props = dbus.Interface(dobj, PROPS_IFACE)
    props.Set(DEVICE_IFACE, "Trusted", dbus.Boolean(trusted))


def remove_device(mac: str, adapter: str = "hci0") -> None:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise AdapterDownError(f"adapter {adapter!r} not found")
    aobj = bus.get_object(BLUEZ_SERVICE, apath)
    aiface = dbus.Interface(aobj, ADAPTER_IFACE)
    dpath = _mac_to_device_path(apath, mac)
    try:
        aiface.RemoveDevice(dpath)
    except dbus.DBusException as e:
        raise RuntimeError(
            f"Remove {mac} on {adapter} failed: {e.get_dbus_message()}"
        ) from e


def list_gatt_tree(mac: str, adapter: str = "hci0") -> dict:
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise AdapterDownError(f"adapter {adapter!r} not found")
    dpath = _mac_to_device_path(apath, mac)
    om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), OM_IFACE)
    objs = om.GetManagedObjects()
    services: dict[str, dict] = {}
    svc_path_to_uuid: dict[str, str] = {}
    for path, ifaces in objs.items():
        if not path.startswith(dpath + "/"):
            continue
        if GATT_SERVICE_IFACE in ifaces:
            uuid = str(ifaces[GATT_SERVICE_IFACE].get("UUID", ""))
            svc_path_to_uuid[path] = uuid
            services[uuid] = {"path": path, "characteristics": {}}
    for path, ifaces in objs.items():
        if not path.startswith(dpath + "/"):
            continue
        if GATT_CHAR_IFACE in ifaces:
            cp = ifaces[GATT_CHAR_IFACE]
            svc_path = str(cp.get("Service", ""))
            svc_uuid = svc_path_to_uuid.get(svc_path)
            if svc_uuid is None:
                continue
            uuid = str(cp.get("UUID", ""))
            services[svc_uuid]["characteristics"][uuid] = {
                "path": path,
                "flags": [str(f) for f in (cp.get("Flags") or [])],
                "descriptors": {},
            }
    for path, ifaces in objs.items():
        if not path.startswith(dpath + "/"):
            continue
        if GATT_DESC_IFACE in ifaces:
            d_props = ifaces[GATT_DESC_IFACE]
            char_path = str(d_props.get("Characteristic", ""))
            uuid = str(d_props.get("UUID", ""))
            for sv in services.values():
                for ch in sv["characteristics"].values():
                    if ch["path"] == char_path:
                        ch["descriptors"][uuid] = {"path": path}
                        break
    return services


def gatt_read(mac: str, char_uuid: str, adapter: str = "hci0") -> bytes:
    tree = list_gatt_tree(mac, adapter)
    char_path = None
    for sv in tree.values():
        if char_uuid in sv["characteristics"]:
            char_path = sv["characteristics"][char_uuid]["path"]
            break
    if char_path is None:
        raise RuntimeError(f"characteristic {char_uuid} not found on {mac}")
    bus = get_bus()
    cobj = bus.get_object(BLUEZ_SERVICE, char_path)
    ciface = dbus.Interface(cobj, GATT_CHAR_IFACE)
    return bytes(ciface.ReadValue({}))


def gatt_write(mac: str, char_uuid: str, payload: bytes,
               adapter: str = "hci0", with_response: bool = True) -> None:
    tree = list_gatt_tree(mac, adapter)
    char_path = None
    for sv in tree.values():
        if char_uuid in sv["characteristics"]:
            char_path = sv["characteristics"][char_uuid]["path"]
            break
    if char_path is None:
        raise RuntimeError(f"characteristic {char_uuid} not found on {mac}")
    bus = get_bus()
    cobj = bus.get_object(BLUEZ_SERVICE, char_path)
    ciface = dbus.Interface(cobj, GATT_CHAR_IFACE)
    opts = {"type": dbus.String("request" if with_response else "command")}
    ciface.WriteValue([dbus.Byte(b) for b in payload], opts)


PROFILE_UUIDS = {
    "0000110a": "A2DP Source", "0000110b": "A2DP Sink",
    "0000110c": "AVRCP Target", "0000110e": "AVRCP Controller",
    "0000111e": "Handsfree", "0000111f": "Handsfree AG",
    "00001108": "Headset", "00001112": "Headset AG",
    "00001105": "OBEX Object Push", "00001106": "OBEX File Transfer",
    "00001130": "Phonebook Access (PBAP) PSE",
    "0000112e": "Phonebook Access (PBAP) PCE",
    "00001132": "Message Access (MAP) MSE",
    "00001134": "Message Access (MAP) MCE",
    "00001124": "HID", "00001200": "PnP",
    "0000180f": "Battery Service", "0000180a": "Device Information",
    "00001800": "Generic Access", "00001801": "Generic Attribute",
    "0000fe9f": "Google", "0000feaa": "Eddystone",
}


def detect_profiles(uuids: list[str]) -> list[str]:
    found = []
    for u in uuids:
        prefix = u.lower()[:8]
        if prefix in PROFILE_UUIDS:
            found.append(PROFILE_UUIDS[prefix])
    return sorted(set(found))


path: bin/bt_media.py
content: """
bt_media.py — AVRCP / MediaPlayer1 control for the bluetooth skill.

When a phone connects with AVRCP-CT (controller) advertised, BlueZ creates a
MediaPlayer1 D-Bus object exposing the phone's currently-active player. We
can:
  - Read current track metadata (Title, Artist, Album, Duration, Position)
  - Read playback status (playing, paused, stopped)
  - Send transport commands (Play, Pause, Stop, Next, Previous)
  - Read playlist state

BlueZ paths look like:
  /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/player0
"""
from __future__ import annotations
import sys
from typing import Optional

import dbus

from bt_lib import (BLUEZ_SERVICE, OM_IFACE, PROPS_IFACE, get_bus,
                    adapter_path, _mac_to_device_path, _device_path_to_mac)

MEDIA_PLAYER_IFACE = "org.bluez.MediaPlayer1"
MEDIA_TRANSPORT_IFACE = "org.bluez.MediaTransport1"
MEDIA_CONTROL_IFACE = "org.bluez.MediaControl1"  # legacy AVRCP1.3
MEDIA_FOLDER_IFACE = "org.bluez.MediaFolder1"
MEDIA_ITEM_IFACE = "org.bluez.MediaItem1"


def _dbus_to_py(v):
    if isinstance(v, dbus.Dictionary):
        return {str(k): _dbus_to_py(x) for k, x in v.items()}
    if isinstance(v, (dbus.Array, list, tuple)):
        return [_dbus_to_py(x) for x in v]
    if isinstance(v, (dbus.String, dbus.ObjectPath)):
        return str(v)
    if isinstance(v, dbus.Boolean):
        return bool(v)
    if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64,
                       dbus.UInt16, dbus.UInt32, dbus.UInt64,
                       dbus.Byte)):
        return int(v)
    if isinstance(v, dbus.Double):
        return float(v)
    return v


def find_player(mac: str, adapter: str = "hci0") -> Optional[dict]:
    """Find the MediaPlayer1 object for a given device MAC.

    Returns dict with path + properties, or None if no player object exists.
    """
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise RuntimeError(f"adapter {adapter!r} not found")
    dpath = _mac_to_device_path(apath, mac)
    om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), OM_IFACE)
    for path, ifaces in om.GetManagedObjects().items():
        if not path.startswith(dpath + "/"):
            continue
        if MEDIA_PLAYER_IFACE in ifaces:
            return {
                "path": str(path),
                **{str(k): _dbus_to_py(v)
                   for k, v in ifaces[MEDIA_PLAYER_IFACE].items()},
            }
    return None


def find_transport(mac: str, adapter: str = "hci0") -> Optional[dict]:
    """Find the MediaTransport1 (audio stream metadata)."""
    bus = get_bus()
    apath = adapter_path(adapter)
    if apath is None:
        raise RuntimeError(f"adapter {adapter!r} not found")
    dpath = _mac_to_device_path(apath, mac)
    om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), OM_IFACE)
    for path, ifaces in om.GetManagedObjects().items():
        if not path.startswith(dpath + "/"):
            continue
        if MEDIA_TRANSPORT_IFACE in ifaces:
            return {
                "path": str(path),
                **{str(k): _dbus_to_py(v)
                   for k, v in ifaces[MEDIA_TRANSPORT_IFACE].items()},
            }
    return None


def player_action(mac: str, action: str, adapter: str = "hci0") -> None:
    """Send an AVRCP transport command.

    action: Play | Pause | Stop | Next | Previous | FastForward | Rewind
    """
    p = find_player(mac, adapter)
    if p is None:
        raise RuntimeError(
            f"No MediaPlayer1 for {mac}. Phone might not have AVRCP active "
            f"or no media is loaded."
        )
    bus = get_bus()
    pobj = bus.get_object(BLUEZ_SERVICE, p["path"])
    iface = dbus.Interface(pobj, MEDIA_PLAYER_IFACE)
    method = getattr(iface, action)
    method()


def media_status(mac: str, adapter: str = "hci0") -> dict:
    """Get current track + playback status as a flat dict."""
    p = find_player(mac, adapter)
    if p is None:
        return {"connected": False, "reason": "no MediaPlayer1 object"}
    track = p.get("Track", {}) or {}
    return {
        "connected": True,
        "player_path": p["path"],
        "name": p.get("Name", ""),
        "type": p.get("Type", ""),
        "subtype": p.get("Subtype", ""),
        "status": p.get("Status", "unknown"),
        "position_ms": p.get("Position", 0),
        "shuffle": p.get("Shuffle", "off"),
        "repeat": p.get("Repeat", "off"),
        "title": track.get("Title", ""),
        "artist": track.get("Artist", ""),
        "album": track.get("Album", ""),
        "genre": track.get("Genre", ""),
        "duration_ms": track.get("Duration", 0),
        "track_number": track.get("TrackNumber", 0),
        "track_count": track.get("NumberOfTracks", 0),
    }


path: bin/bt_obex.py
content: """
bt_obex.py — OBEX file transfer (push / pull / browse) for the bluetooth skill.

Talks to bluez-obexd via D-Bus on the SESSION bus (not the system bus —
this is critical: org.bluez.obex is per-user). Implements:

  * push_file(mac, path)             — OBEX Object Push (OPP, profile 0x1105)
  * pull_file(mac, remote_name)      — OBEX FTP (profile 0x1106), receive a file
  * list_folder(mac, remote_path)    — Browse the device's filesystem (FTP)
  * register_receive_agent()         — Sit in the foreground, accept incoming pushes,
                                       save to ~/Downloads/bluetooth/

Wraps `org.bluez.obex.Client1`, `org.bluez.obex.Session1`, `Transfer1`,
`ObjectPush1`, `FileTransfer1`, and the agent registration interface.
"""
from __future__ import annotations

import os
import sys
import time
from pathlib import Path
from typing import Optional

import dbus
import dbus.mainloop.glib
import dbus.service
from gi.repository import GLib

OBEX_BUS = "org.bluez.obex"
OBEX_PATH = "/org/bluez/obex"
CLIENT_IFACE = "org.bluez.obex.Client1"
SESSION_IFACE = "org.bluez.obex.Session1"
TRANSFER_IFACE = "org.bluez.obex.Transfer1"
OBJECT_PUSH_IFACE = "org.bluez.obex.ObjectPush1"
FILE_TRANSFER_IFACE = "org.bluez.obex.FileTransfer1"
AGENT_MANAGER_IFACE = "org.bluez.obex.AgentManager1"
AGENT_IFACE = "org.bluez.obex.Agent1"
PROPS_IFACE = "org.freedesktop.DBus.Properties"

DEFAULT_INCOMING = Path(os.path.expanduser("~/Downloads/bluetooth"))


def _session_bus():
    if not getattr(_session_bus, "_set_default", False):
        dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
        _session_bus._set_default = True
    return dbus.SessionBus()


def _client():
    bus = _session_bus()
    return dbus.Interface(bus.get_object(OBEX_BUS, OBEX_PATH), CLIENT_IFACE)


def _create_session(mac: str, target: str) -> str:
    """Create an OBEX session. target ∈ {opp, ftp, pbap, map, sync}."""
    args = dbus.Dictionary({"Target": target}, signature="sv")
    return str(_client().CreateSession(mac.upper(), args))


def _wait_for_transfer(transfer_path: str, timeout: float = 120.0) -> dict:
    """Block until the transfer completes; return the final properties dict."""
    bus = _session_bus()
    tobj = bus.get_object(OBEX_BUS, transfer_path)
    props_iface = dbus.Interface(tobj, PROPS_IFACE)

    loop = GLib.MainLoop()
    final: dict = {}

    def _on_props(iface, changed, invalidated):
        if iface != TRANSFER_IFACE:
            return
        status = changed.get("Status")
        if status:
            final["status"] = str(status)
            if str(status) in ("complete", "error"):
                loop.quit()

    sig = bus.add_signal_receiver(
        _on_props,
        signal_name="PropertiesChanged",
        dbus_interface=PROPS_IFACE,
        path=transfer_path,
    )

    # Timeout
    GLib.timeout_add_seconds(int(timeout), loop.quit)

    # Initial state check (transfer may already have completed)
    try:
        all_props = props_iface.GetAll(TRANSFER_IFACE)
        if str(all_props.get("Status", "")) in ("complete", "error"):
            final.update(dict(all_props))
            sig.remove()
            return {k: str(v) for k, v in all_props.items()}
    except dbus.DBusException:
        pass

    loop.run()
    sig.remove()

    try:
        all_props = props_iface.GetAll(TRANSFER_IFACE)
        return {k: str(v) for k, v in all_props.items()}
    except dbus.DBusException:
        return final


def push_file(mac: str, file_path: str, timeout: float = 120.0) -> dict:
    """OBEX Object Push: send a file to a paired phone/device."""
    file_path = os.path.abspath(os.path.expanduser(file_path))
    if not os.path.exists(file_path):
        raise FileNotFoundError(file_path)

    bus = _session_bus()
    session_path = _create_session(mac, "opp")
    sobj = bus.get_object(OBEX_BUS, session_path)
    pusher = dbus.Interface(sobj, OBJECT_PUSH_IFACE)

    transfer_path, _props = pusher.SendFile(file_path)
    result = _wait_for_transfer(str(transfer_path), timeout=timeout)

    # Clean up session
    try:
        _client().RemoveSession(session_path)
    except dbus.DBusException:
        pass

    return {
        "mac": mac.upper(),
        "file": file_path,
        "size": os.path.getsize(file_path),
        "transfer_status": result.get("Status", "unknown"),
        "transferred": result.get("Transferred"),
    }


def pull_file(mac: str, remote_name: str, save_to: Optional[str] = None,
              timeout: float = 120.0) -> dict:
    """OBEX FTP: pull a file by name from the device. (FTP profile, not OPP.)"""
    if save_to is None:
        save_to = str(DEFAULT_INCOMING / os.path.basename(remote_name))
    save_to = os.path.abspath(os.path.expanduser(save_to))
    Path(save_to).parent.mkdir(parents=True, exist_ok=True)

    bus = _session_bus()
    session_path = _create_session(mac, "ftp")
    sobj = bus.get_object(OBEX_BUS, session_path)
    ftp = dbus.Interface(sobj, FILE_TRANSFER_IFACE)

    transfer_path, _props = ftp.GetFile(save_to, remote_name)
    result = _wait_for_transfer(str(transfer_path), timeout=timeout)

    try:
        _client().RemoveSession(session_path)
    except dbus.DBusException:
        pass

    return {
        "mac": mac.upper(),
        "remote": remote_name,
        "saved_to": save_to,
        "status": result.get("Status", "unknown"),
    }


def list_folder(mac: str, folder: str = "/") -> list[dict]:
    """OBEX FTP: list folder contents on the device."""
    bus = _session_bus()
    session_path = _create_session(mac, "ftp")
    sobj = bus.get_object(OBEX_BUS, session_path)
    ftp = dbus.Interface(sobj, FILE_TRANSFER_IFACE)
    try:
        if folder != "/":
            ftp.ChangeFolder(folder)
        listing = ftp.ListFolder()
    finally:
        try:
            _client().RemoveSession(session_path)
        except dbus.DBusException:
            pass
    out = []
    for entry in listing:
        out.append({k: str(v) for k, v in entry.items()})
    return out


# ---------------------------------------------------------------------------
# Receive agent — accepts incoming pushes, saves to ~/Downloads/bluetooth
# ---------------------------------------------------------------------------
class ObexReceiveAgent(dbus.service.Object):
    AGENT_PATH = "/openclaw/obex/agent"

    def __init__(self, bus, save_dir: Path):
        super().__init__(bus, self.AGENT_PATH)
        self.save_dir = save_dir
        save_dir.mkdir(parents=True, exist_ok=True)
        self._pending_paths: dict[str, str] = {}

    def _log(self, msg):
        sys.stderr.write(f"[obex-recv] {msg}\n")
        sys.stderr.flush()

    @dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="s")
    def AuthorizePush(self, transfer_path):
        """Called when a peer wants to push a file. Return target filesystem path."""
        bus = _session_bus()
        tobj = bus.get_object(OBEX_BUS, transfer_path)
        props = dbus.Interface(tobj, PROPS_IFACE)
        try:
            name = str(props.Get(TRANSFER_IFACE, "Name"))
            size = int(props.Get(TRANSFER_IFACE, "Size"))
        except dbus.DBusException:
            name = f"unknown-{int(time.time())}"
            size = 0
        # Make name safe
        safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in name) or f"recv-{int(time.time())}"
        target = self.save_dir / safe
        # If exists, append timestamp
        if target.exists():
            ts = time.strftime("%Y%m%d-%H%M%S")
            target = target.with_name(f"{target.stem}-{ts}{target.suffix}")
        self._pending_paths[str(transfer_path)] = str(target)
        self._log(f"AuthorizePush {name} ({size} bytes) -> {target}")
        return str(target)

    @dbus.service.method(AGENT_IFACE, in_signature="", out_signature="")
    def Cancel(self):
        self._log("Cancel")

    @dbus.service.method(AGENT_IFACE, in_signature="", out_signature="")
    def Release(self):
        self._log("Release")


def run_receive_agent(save_dir: Optional[str] = None) -> int:
    """Foreground: register an OBEX agent and wait for incoming files. Ctrl-C to stop."""
    save_path = Path(save_dir or DEFAULT_INCOMING).expanduser()
    bus = _session_bus()
    agent = ObexReceiveAgent(bus, save_path)
    manager = dbus.Interface(bus.get_object(OBEX_BUS, OBEX_PATH), AGENT_MANAGER_IFACE)
    try:
        manager.RegisterAgent(ObexReceiveAgent.AGENT_PATH)
    except dbus.DBusException as e:
        if "AlreadyExists" in str(e):
            sys.stderr.write("OBEX agent already registered (another receiver running?)\n")
            return 1
        raise
    sys.stderr.write(f"[obex-recv] listening, saving incoming files to {save_path}\n")

    loop = GLib.MainLoop()

    def _shutdown(*_a):
        try:
            manager.UnregisterAgent(ObexReceiveAgent.AGENT_PATH)
        except dbus.DBusException:
            pass
        loop.quit()

    import signal as _signal
    _signal.signal(_signal.SIGTERM, _shutdown)
    _signal.signal(_signal.SIGINT, _shutdown)

    try:
        loop.run()
    finally:
        try:
            manager.UnregisterAgent(ObexReceiveAgent.AGENT_PATH)
        except dbus.DBusException:
            pass
    return 0


path: bin/bt_obex_msg.py
content: """
bt_obex_msg.py — PBAP (contacts) + MAP (SMS) over OBEX, for the bluetooth skill.

Builds on bt_obex.py's session abstraction, but adds session targets:
  - "pbap" (Phone Book Access Profile)   — contacts
  - "map"  (Message Access Profile)       — SMS, MMS, email folders on phone

Phones expose these to a paired+trusted host. iOS exposes PBAP. Android
needs the user to enable "Phonebook access" / "Message access" in the
Bluetooth pair settings on a per-host basis.
"""
from __future__ import annotations

from typing import Optional

import dbus

from bt_obex import (_session_bus, _client, _wait_for_transfer,
                     OBEX_BUS, CLIENT_IFACE)

PBAP_IFACE = "org.bluez.obex.PhonebookAccess1"
MAP_IFACE = "org.bluez.obex.MessageAccess1"
MAP_FOLDER_IFACE = "org.bluez.obex.MessageAccess1"  # same iface, ChDir/ListMessages methods


# ---------------------------------------------------------------------------
# PBAP — contacts
# ---------------------------------------------------------------------------
def pbap_list_contacts(mac: str, repo: str = "internal",
                        max_count: int = 0) -> list[dict]:
    """List contacts from the phone's address book.

    repo: one of 'internal' (phone) or 'sim'.
    max_count: 0 = all (subject to phone's limits).
    """
    bus = _session_bus()
    args = dbus.Dictionary({"Target": "pbap"}, signature="sv")
    session_path = str(_client().CreateSession(mac.upper(), args))
    sobj = bus.get_object(OBEX_BUS, session_path)
    pbap = dbus.Interface(sobj, PBAP_IFACE)

    try:
        # Select the phone book.
        pbap.Select(repo, "pb")
        # The List method returns vcard handles; the Pull method gets the actual cards.
        filters = dbus.Dictionary(
            {"MaxCount": dbus.UInt16(max_count)} if max_count else {},
            signature="sv",
        )
        listing = pbap.List(filters)
    finally:
        try:
            _client().RemoveSession(session_path)
        except dbus.DBusException:
            pass

    out = []
    for entry in listing:
        # entry is (handle: str, name: str)
        out.append({"handle": str(entry[0]), "name": str(entry[1])})
    return out


def pbap_pull_all_vcards(mac: str, repo: str = "internal",
                         save_to: str = None,
                         timeout: float = 60.0) -> dict:
    """Pull the entire phonebook as a single .vcf file."""
    import os
    if save_to is None:
        save_to = os.path.expanduser(f"~/Downloads/bluetooth/{mac.replace(':', '')}-{repo}.vcf")
    os.makedirs(os.path.dirname(save_to), exist_ok=True)

    bus = _session_bus()
    args = dbus.Dictionary({"Target": "pbap"}, signature="sv")
    session_path = str(_client().CreateSession(mac.upper(), args))
    sobj = bus.get_object(OBEX_BUS, session_path)
    pbap = dbus.Interface(sobj, PBAP_IFACE)

    try:
        pbap.Select(repo, "pb")
        filters = dbus.Dictionary({"Format": "vcard30"}, signature="sv")
        transfer_path, _props = pbap.PullAll(save_to, filters)
        result = _wait_for_transfer(str(transfer_path), timeout=timeout)
    finally:
        try:
            _client().RemoveSession(session_path)
        except dbus.DBusException:
            pass

    return {
        "mac": mac.upper(),
        "saved_to": save_to,
        "status": result.get("Status", "unknown"),
        "size": result.get("Size"),
    }


# ---------------------------------------------------------------------------
# MAP — SMS / messages
# ---------------------------------------------------------------------------
def map_list_messages(mac: str, folder: str = "telecom/msg/inbox",
                      max_count: int = 25) -> list[dict]:
    """List recent messages from a folder on the phone (SMS/MMS via MAP)."""
    bus = _session_bus()
    args = dbus.Dictionary({"Target": "map"}, signature="sv")
    session_path = str(_client().CreateSession(mac.upper(), args))
    sobj = bus.get_object(OBEX_BUS, session_path)
    map_iface = dbus.Interface(sobj, MAP_IFACE)

    try:
        map_iface.SetFolder(folder)
        filters = dbus.Dictionary(
            {"MaxCount": dbus.UInt16(max_count)} if max_count else {},
            signature="sv",
        )
        listing = map_iface.ListMessages("", filters)
    finally:
        try:
            _client().RemoveSession(session_path)
        except dbus.DBusException:
            pass

    out = []
    # BlueZ MAP returns a dict {ObjectPath: {properties...}}, not a list of tuples
    if hasattr(listing, "items"):
        for path, props in listing.items():
            d = {"path": str(path)}
            if hasattr(props, "items"):
                for k, v in props.items():
                    d[str(k)] = str(v)
            out.append(d)
    else:
        for entry in listing:
            d = {"path": str(entry[0]) if entry else "?"}
            if len(entry) > 1 and hasattr(entry[1], "items"):
                for k, v in entry[1].items():
                    d[str(k)] = str(v)
            out.append(d)
    return out


def map_send_message(mac: str, number: str, text: str,
                     folder: str = "telecom/msg/outbox",
                     timeout: float = 30.0) -> dict:
    """Send an SMS through the paired phone (MAP profile).

    Note: MAP is the read/management API. For sending, oFono's MessageManager
    on a connected HFP modem is usually more reliable on Android. This MAP
    sender is a fallback when ofono refuses (some phones expose MAP-send but
    not HFP-send, or vice versa).
    """
    import os
    import tempfile
    bus = _session_bus()
    args = dbus.Dictionary({"Target": "map"}, signature="sv")
    session_path = str(_client().CreateSession(mac.upper(), args))
    sobj = bus.get_object(OBEX_BUS, session_path)
    map_iface = dbus.Interface(sobj, MAP_IFACE)

    # Probe whether the MAP server actually accepts writes. Many Android
    # devices (notably Samsung) advertise MAP-MSE but only implement the
    # read-side methods. UpdateInbox is the canonical write-probe.
    try:
        map_iface.UpdateInbox()
    except dbus.DBusException as e:
        if "Not Implemented" in str(e):
            try:
                _client().RemoveSession(session_path)
            except dbus.DBusException:
                pass
            return {
                "mac": mac.upper(),
                "to": number,
                "status": "rejected_read_only",
                "reason": "phone has read-only MAP — UpdateInbox not implemented. "
                          "Samsung deliberately disables MAP write methods. "
                          "SMS-send via MAP is not possible on this phone."
            }

    # Build a minimal bMessage envelope
    bmsg = (
        "BEGIN:BMSG\r\n"
        "VERSION:1.0\r\n"
        "STATUS:READ\r\n"
        "TYPE:SMS_GSM\r\n"
        "FOLDER:telecom/msg/outbox\r\n"
        "BEGIN:VCARD\r\nVERSION:2.1\r\n"
        f"TEL:{number}\r\n"
        "END:VCARD\r\n"
        "BEGIN:BENV\r\n"
        "BEGIN:BBODY\r\n"
        "ENCODING:8BIT\r\n"
        "CHARSET:UTF-8\r\n"
        f"LENGTH:{len(text)}\r\n"
        "BEGIN:MSG\r\n"
        f"{text}\r\n"
        "END:MSG\r\n"
        "END:BBODY\r\n"
        "END:BENV\r\n"
        "END:BMSG\r\n"
    )
    fd, path = tempfile.mkstemp(prefix="bmsg-", suffix=".bmsg")
    try:
        with os.fdopen(fd, "w") as f:
            f.write(bmsg)
        try:
            map_iface.SetFolder(folder)
        except dbus.DBusException:
            pass
        transfer_path, _props = map_iface.PushMessage(
            path,
            folder,
            dbus.Dictionary({"Transparent": dbus.Boolean(False)},
                             signature="sv")
        )
        result = _wait_for_transfer(str(transfer_path), timeout=timeout)
    finally:
        try:
            os.unlink(path)
        except OSError:
            pass
        try:
            _client().RemoveSession(session_path)
        except dbus.DBusException:
            pass

    return {
        "mac": mac.upper(),
        "to": number,
        "status": result.get("Status", "unknown"),
    }


path: bin/bt_telephony.py
content: """
bt_telephony.py — ofono telephony for the OpenClaw `bluetooth` skill.

ofono provides a D-Bus API on the SYSTEM bus (org.ofono) that exposes
HFP modems, voice calls, and message manager. When a paired phone
connects with the Hands-Free profile, ofono auto-creates a modem object
for it whose path embeds the phone's BD address.

This module wraps:
  * list_modems()                 — enumerate ofono modems (1 per HFP phone)
  * modem_for_mac(mac)            — find the modem whose BD matches
  * dial(modem, number)           — start an outgoing call
  * answer(modem) / hangup(modem) — control current call
  * list_calls(modem)             — list current voice calls
  * sms_send(modem, num, text)    — outgoing SMS via MessageManager
  * sms_list(modem)               — list inbound SMS (recent only — ofono
                                    doesn't keep history; use MAP via OBEX
                                    for historical SMS — see bt_obex_msg.py)

Requires: dbus-1 policy at /etc/dbus-1/system.d/ofono-${USER}.conf
allowing user `${USER}` to send to `org.ofono`.
"""
from __future__ import annotations

import sys
from typing import Optional

import dbus
import dbus.mainloop.glib


OFONO_BUS = "org.ofono"
MANAGER_IFACE = "org.ofono.Manager"
MODEM_IFACE = "org.ofono.Modem"
VOICE_CALL_MGR_IFACE = "org.ofono.VoiceCallManager"
VOICE_CALL_IFACE = "org.ofono.VoiceCall"
MESSAGE_MGR_IFACE = "org.ofono.MessageManager"
HANDSFREE_IFACE = "org.ofono.Handsfree"
NETWORK_REG_IFACE = "org.ofono.NetworkRegistration"


def _bus():
    if not getattr(_bus, "_set_default", False):
        dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
        _bus._set_default = True
    return dbus.SystemBus()


def list_modems() -> list[dict]:
    bus = _bus()
    mgr = dbus.Interface(bus.get_object(OFONO_BUS, "/"), MANAGER_IFACE)
    out = []
    for path, props in mgr.GetModems():
        d = {"path": str(path)}
        for k, v in props.items():
            d[str(k)] = _dbus_to_py(v)
        out.append(d)
    return out


def _dbus_to_py(v):
    if isinstance(v, dbus.Dictionary):
        return {str(k): _dbus_to_py(x) for k, x in v.items()}
    if isinstance(v, (dbus.Array, list, tuple)):
        return [_dbus_to_py(x) for x in v]
    if isinstance(v, (dbus.String, dbus.ObjectPath)):
        return str(v)
    if isinstance(v, dbus.Boolean):
        return bool(v)
    if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64,
                       dbus.UInt16, dbus.UInt32, dbus.UInt64,
                       dbus.Byte)):
        return int(v)
    if isinstance(v, dbus.Double):
        return float(v)
    return v


def modem_for_mac(mac: str) -> Optional[dict]:
    """Find the ofono modem whose path contains the BD address of the given MAC.

    ofono path format for HFP modems: /hfp/<adapter_bd>/dev_<peer_bd>
    e.g. /hfp/441CA8A51398/dev_AABBCCDDEEFF
    """
    target = mac.upper().replace(":", "")
    for m in list_modems():
        if target in m["path"].upper().replace("_", ""):
            return m
    return None


def power_modem(modem_path: str, on: bool = True) -> None:
    bus = _bus()
    mobj = bus.get_object(OFONO_BUS, modem_path)
    miface = dbus.Interface(mobj, MODEM_IFACE)
    miface.SetProperty("Powered", dbus.Boolean(on))


def online_modem(modem_path: str, on: bool = True) -> None:
    bus = _bus()
    mobj = bus.get_object(OFONO_BUS, modem_path)
    miface = dbus.Interface(mobj, MODEM_IFACE)
    miface.SetProperty("Online", dbus.Boolean(on))


# ---------------------------------------------------------------------------
# Voice calls
# ---------------------------------------------------------------------------
def list_calls(modem_path: str) -> list[dict]:
    bus = _bus()
    mgr = dbus.Interface(bus.get_object(OFONO_BUS, modem_path),
                          VOICE_CALL_MGR_IFACE)
    out = []
    for path, props in mgr.GetCalls():
        d = {"path": str(path)}
        for k, v in props.items():
            d[str(k)] = _dbus_to_py(v)
        out.append(d)
    return out


def dial(modem_path: str, number: str, hide_caller_id: bool = False) -> str:
    """Start an outgoing call. Returns the call's D-Bus path."""
    bus = _bus()
    mgr = dbus.Interface(bus.get_object(OFONO_BUS, modem_path),
                          VOICE_CALL_MGR_IFACE)
    hide = "enabled" if hide_caller_id else "default"
    call_path = mgr.Dial(number, hide)
    return str(call_path)


def answer_first(modem_path: str) -> Optional[str]:
    """Answer the first incoming call. Returns its path, or None if none ringing."""
    bus = _bus()
    for c in list_calls(modem_path):
        if c.get("State") in ("incoming", "waiting"):
            cobj = bus.get_object(OFONO_BUS, c["path"])
            iface = dbus.Interface(cobj, VOICE_CALL_IFACE)
            iface.Answer()
            return c["path"]
    return None


def hangup_all(modem_path: str) -> int:
    """Terminate every active/incoming/dialing call. Returns count terminated."""
    bus = _bus()
    mgr = dbus.Interface(bus.get_object(OFONO_BUS, modem_path),
                          VOICE_CALL_MGR_IFACE)
    calls = list_calls(modem_path)
    if not calls:
        return 0
    mgr.HangupAll()
    return len(calls)


# ---------------------------------------------------------------------------
# SMS via ofono Message Manager (live, not historical)
# ---------------------------------------------------------------------------
def sms_send(modem_path: str, number: str, text: str) -> str:
    bus = _bus()
    msg = dbus.Interface(bus.get_object(OFONO_BUS, modem_path),
                          MESSAGE_MGR_IFACE)
    return str(msg.SendMessage(number, text))


def sms_list_outgoing(modem_path: str) -> list[dict]:
    """Pending outgoing messages still being delivered."""
    bus = _bus()
    msg = dbus.Interface(bus.get_object(OFONO_BUS, modem_path),
                          MESSAGE_MGR_IFACE)
    out = []
    for path, props in msg.GetMessages():
        d = {"path": str(path)}
        for k, v in props.items():
            d[str(k)] = _dbus_to_py(v)
        out.append(d)
    return out


# ---------------------------------------------------------------------------
# Network / signal
# ---------------------------------------------------------------------------
def network_state(modem_path: str) -> dict:
    bus = _bus()
    mobj = bus.get_object(OFONO_BUS, modem_path)
    try:
        netreg = dbus.Interface(mobj, NETWORK_REG_IFACE)
        props = netreg.GetProperties()
        return {str(k): _dbus_to_py(v) for k, v in props.items()}
    except dbus.DBusException:
        return {}


# ---------------------------------------------------------------------------
# Convenience: get full state of a modem (one dict)
# ---------------------------------------------------------------------------
def modem_state(modem_path: str) -> dict:
    bus = _bus()
    mobj = bus.get_object(OFONO_BUS, modem_path)
    miface = dbus.Interface(mobj, MODEM_IFACE)
    try:
        props = miface.GetProperties()
    except dbus.DBusException as e:
        return {"path": modem_path, "error": str(e)}
    state = {"path": modem_path,
             **{str(k): _dbus_to_py(v) for k, v in props.items()}}
    state["calls"] = list_calls(modem_path) if "VoiceCallManager" in (state.get("Interfaces") or []) else []
    state["network"] = network_state(modem_path)
    return state


path: CHANGELOG-v2.0.0.md
content: # Paired: Phone Agent — v2.0.0 — Voice cloning release

**Release date:** 2026-05-17

**Theme:** The skill grew up. v1 was "pair my Bluetooth headset." v2.0.0 is "give my agent a body — phone, voice, and all."

The name has been refined to **Paired: Phone Agent** in all public-facing documentation to reflect the actual scope of what the skill does. The ClawHub slug `paired` is unchanged so existing installs continue to work seamlessly.

---

## Major changes

### Added — voice cloning subsystem

Paired now optionally clones the user's own voice for all synthesised speech, with privacy-first design: no cloud calls, no upstream training data, all weights local.

* `skill/voice/paired-voice-synth.py` — TTS wrapper with 4-level fallback ladder
* `skill/engines/voxcpm-server.py` + `voxcpm.Dockerfile` — VoxCPM2 HTTP service (primary engine)
* `skill/engines/xtts.Dockerfile` — XTTS v2 HTTP service (fallback)
* `skill/setup/paired-voice-setup.py` — guided 5-minute training flow
* `skill/config-templates/voice.conf.example` — config template

### Added — word-level audio splicing

Pre-recorded clips of specific words (typically the user's name, family names, brand names) are spliced into synthesised output for 100% accurate pronunciation. Drop WAV files into `~/.config/paired/voice/word-clips/`; the synth wrapper detects matches at word boundaries (case-insensitive).

Solves the universal "AI mispronounces my name" problem permanently. Your name is now pronounced by you, every time.

### Added — 30-language multilingual

Via VoxCPM2: auto-detected language support across 30 languages from input text. No flag needed.

Supported languages: Arabic, Burmese, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Indonesian, Italian, Japanese, Khmer, Korean, Lao, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, Tagalog, Thai, Turkish, Vietnamese.

### Added — long-form chunking

The synth wrapper splits long text at sentence boundaries before calling the cloning engine, avoiding the ~400-token soft limit and GPU OOM seen on 1500+ character inputs. Concatenation is gap-free; listeners cannot hear the joins. Tested at 2-minute voice notes; scales cleanly to 10+ minutes.

### Added — public documentation

* `README.md` (top level) — rewritten for the v2.0.0 "Paired: Phone Agent" identity
* `skill/docs/VOICE-SETUP.md` — full voice setup, troubleshooting, multilingual
* `skill/marketing/README-marketing.md` — long-form public pitch
* `skill/THIRD_PARTY.md` — full attribution for all bundled and runtime dependencies

---

## Unchanged

All v1.x functionality is preserved exactly as it was: BlueZ pairing, ADB control, SMS receive (MAP/MNS), SMS send (ADB autosend), outgoing calls (HFP), incoming-call alerts, contacts pull (PBAP), media control (AVRCP), file transfer (OBEX), PAN tethering, trusted-numbers allowlist, HMAC-signed inbox command dispatch, mode 0600 secret-file enforcement.

v2.0.0 is strictly additive.

---

## Removed

Nothing.

---

## Compatibility

* All v1.x configs work unchanged
* If `voice.conf` is absent, paired falls back to piper or espeak-ng — the v1.x behaviour
* Voice cloning is **opt-in** — the skill does nothing voice-related until the user runs `paired-voice-setup.py reference`
* ClawHub slug remains `paired` — existing installs keep working without action

---

## Hardware requirements

| Use case | Floor |
|----------|-------|
| Full feature (cloned voice, 48kHz) | NVIDIA GPU with 6GB+ VRAM |
| Cloned voice (24kHz, XTTS only) | NVIDIA GPU with 4GB VRAM |
| Fallback (generic neural voice) | CPU only — no GPU required |
| Phone bridge alone (no voice cloning) | Same as v1.x — Linux host with BlueZ, Android phone with ADB |

---

## Acknowledgements

VoxCPM2 (OpenBMB), coqui-tts (idiap fork), piper (rhasspy), espeak-ng. Full attribution in [`THIRD_PARTY.md`](THIRD_PARTY.md).

Bug reports and hardware compatibility reports welcome at the issue tracker.


path: docs/VOICE-SETUP.md
content: # Voice Cloning Setup for paired v2.0.0

`paired` v2.0.0 introduces optional voice cloning so the agent can speak with your own voice — for SMS-to-voice dictation, voice notes on Telegram, and live phone replies through your paired device.

## Privacy first

- **Your voice never leaves your hardware.** All synthesis runs locally on your GPU (or CPU fallback).
- **Nothing is bundled with this skill.** The reference WAV and splice clips you record stay in `~/.config/paired/voice/`.
- **No cloud calls.** Models are downloaded once from HuggingFace, then run offline.
- **No training data is shipped upstream.**

## Hardware

| Setup | Quality | Speed |
|-------|---------|-------|
| High-end consumer GPU (16GB+ VRAM, recommended) | 48kHz studio (VoxCPM2) | 5-10s per minute of speech |
| Mid-range GPU (8-12GB VRAM) | 48kHz studio (VoxCPM2) | 10-20s per minute |
| Entry GPU (4-6GB VRAM) | 24kHz (XTTS only) | 5-15s per minute |
| CPU only | 24kHz (XTTS) | 1-5 minutes per minute (slow) |
| No model server | Generic neural (piper) | <1s — no cloning |

## Quick start (assuming Docker + NVIDIA GPU)

### 1. Build and run the VoxCPM2 service

```bash
cd skill/engines
docker build -t paired-voxcpm:latest -f voxcpm.Dockerfile .

mkdir -p ~/.config/paired/voice/word-clips

docker run -d \
  --name paired-voxcpm \
  --gpus all \
  --restart unless-stopped \
  -p 8056:8056 \
  -v ~/.config/paired/voice:/refs \
  -v paired-hf-cache:/root/.cache/huggingface \
  paired-voxcpm:latest
```

First run downloads ~5GB of model weights from HuggingFace (one-time).

### 2. Record your reference voice

```bash
python3 skill/setup/paired-voice-setup.py reference
```

You will be prompted to read 6 phrases. Takes about 5 minutes. Quiet room, same mic distance for every phrase.

### 3. (Optional) Record splice clips for tricky words

If you have an unusual name or word the model mispronounces, record it yourself:

```bash
python3 skill/setup/paired-voice-setup.py word myname
```

The clip lives in `~/.config/paired/voice/word-clips/myname.wav`. The synth wrapper splices it whenever the text contains "myname" (case-insensitive, whole-word).

You can add as many splice words as you like.

### 4. Test

```bash
python3 skill/voice/paired-voice-synth.py "Hello, this is my cloned voice." /tmp/test.wav
mpg123 /tmp/test.wav   # or aplay
```

### 5. Wire into paired

Edit `~/.config/paired/voice.conf` to confirm paths. The paired-respond wrapper picks up the config automatically.

## CPU fallback (no GPU)

The same Docker container runs on CPU. Expect 1-5 minutes generation per minute of audio. Useful for offline / low-power setups.

## Multilingual

VoxCPM2 auto-detects language from input text across 30 languages:

> Arabic, Burmese, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Indonesian, Italian, Japanese, Khmer, Korean, Lao, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, Tagalog, Thai, Turkish, Vietnamese.

Pass any of these in `text` and you will hear your cloned voice speaking that language.

## Troubleshooting

**Model not loading.** First run takes 2-4 minutes (model download + warmup). Watch `docker logs -f paired-voxcpm`.

**Generation timeout.** VoxCPM2 has a 400-character soft limit per call. `paired-voice-synth.py` chunks long text automatically at sentence boundaries.

**Sounds like the wrong language.** Make sure you are using `reference_wav_path` mode, not `prompt_wav_path + prompt_text`. The synth wrapper does this correctly by default.

**OOM on GPU.** Restart the container — `docker restart paired-voxcpm`. Reduce `inference_timesteps` in the synth config if it keeps happening.



path: engines/voxcpm-server.py
content: """paired voice-cloning service: VoxCPM2 HTTP server.

Lazy-loads VoxCPM2 model on first request. Listens on port 8056.
Compatible with paired-voice-synth client.
Model: openbmb/VoxCPM2 (Apache-2.0).
"""
import base64
import os
import threading
import time
from pathlib import Path

os.environ["TORCH_COMPILE_DISABLE"] = "1"
os.environ["TORCHDYNAMO_DISABLE"] = "1"

import torch
try:
    torch._dynamo.config.suppress_errors = True
    torch._dynamo.config.disable = True
except Exception:
    pass

import uvicorn
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from voxcpm import VoxCPM

app = FastAPI()
MODEL = None
MODEL_LOAD_TIME = 0.0
MODEL_LOADING = False
MODEL_LOAD_LOCK = threading.Lock()


def load_model_sync():
    global MODEL, MODEL_LOAD_TIME, MODEL_LOADING
    with MODEL_LOAD_LOCK:
        if MODEL is not None:
            return
        MODEL_LOADING = True
        print("[voxcpm-server] Loading VoxCPM2 from HF...", flush=True)
        t0 = time.time()
        try:
            MODEL = VoxCPM.from_pretrained("openbmb/VoxCPM2")
        finally:
            MODEL_LOADING = False
        MODEL_LOAD_TIME = time.time() - t0
        print(f"[voxcpm-server] Model loaded in {MODEL_LOAD_TIME:.1f}s", flush=True)


@app.on_event("startup")
async def startup():
    t = threading.Thread(target=load_model_sync, daemon=True)
    t.start()


@app.get("/health")
def health():
    gpu = torch.cuda.is_available()
    vram_free = None
    if gpu:
        try:
            free, total = torch.cuda.mem_get_info(0)
            vram_free = free / (1024 ** 3)
        except Exception:
            pass
    return {
        "ok": True, "gpu": gpu, "vram_free_gb": vram_free,
        "model_loaded": MODEL is not None, "model_loading": MODEL_LOADING,
        "model_load_time_sec": round(MODEL_LOAD_TIME, 1), "engine": "voxcpm2",
    }


class SynthRequest(BaseModel):
    text: str
    reference: str
    ref_text: str | None = None
    output: str = "/tmp/voxcpm-out.wav"


@app.post("/synth")
def synth(req: SynthRequest):
    if MODEL is None:
        if MODEL_LOADING:
            raise HTTPException(503, "Model still loading, retry in 60s")
        load_model_sync()
    if not Path(req.reference).is_file():
        raise HTTPException(400, f"Reference not found: {req.reference}")

    t0 = time.time()
    try:
        kwargs = {"text": req.text, "reference_wav_path": req.reference}
        if req.ref_text and req.ref_text.strip():
            kwargs["prompt_wav_path"] = req.reference
            kwargs["prompt_text"] = req.ref_text
            kwargs.pop("reference_wav_path", None)

        result = MODEL.generate(**kwargs)

        import numpy as np
        import soundfile as sf
        if isinstance(result, tuple):
            audio, sr = result
        elif isinstance(result, np.ndarray):
            audio, sr = result, 48000
        else:
            raise RuntimeError(f"Unexpected generate() return type: {type(result)}")

        Path(req.output).parent.mkdir(parents=True, exist_ok=True)
        sf.write(req.output, audio, sr)
        elapsed = time.time() - t0

        with open(req.output, "rb") as f:
            audio_b64 = base64.b64encode(f.read()).decode()

        return {
            "ok": True, "elapsed_sec": round(elapsed, 2),
            "device": "cuda" if torch.cuda.is_available() else "cpu",
            "sample_rate": sr, "audio_base64": audio_b64,
            "output_path": req.output,
            "mode": "continuation" if req.ref_text else "reference_clone",
        }
    except Exception as e:
        import traceback
        traceback.print_exc()
        return {"ok": False, "error": str(e), "elapsed_sec": round(time.time() - t0, 2)}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8056, timeout_keep_alive=600)


path: marketing/README-marketing.md
content: # Paired: Phone Agent

**Give your AI agent a body. Use the phone in your pocket.**

> *Your assistant can already answer questions. Now it can answer calls, send texts, read your notifications, and speak in your own voice — all through the phone you already own.*

---

## The pitch in one screen

Today, when an AI agent needs to "do something in the real world," it almost always means renting infrastructure:

* A Twilio number to send an SMS
* A Vapi/Bland account to make a phone call
* An ElevenLabs subscription for a voice
* A cloud TTS bill that grows with every notification

You end up with **three monthly subscriptions, a stack of API keys, and a robot voice that isn't yours** — just to do what the phone on your desk already does.

**Paired: Phone Agent** takes the other path. It bridges your OpenClaw agent to your **own** phone over Bluetooth and ADB, and now in v2.0.0 it adds **on-device voice cloning** so the agent speaks with **your** voice. No second SIM. No rented number. No cloud TTS. Your existing phone, your existing number, your existing voice — driven by an agent that knows your context.

---

## What you can actually do

Once installed and paired, your agent gets these commands. They run against the phone in your pocket, on your carrier, with your number on the caller ID.

| Command | What it does | Uses |
|---------|--------------|------|
| `/sms send "Tell mum I will be late"` | Sends a real SMS from your phone | ADB |
| `/sms read` | Pulls your unread texts into the agent context | MAP profile |
| `/call dial 0123...` | Places a real phone call through your carrier | HFP profile |
| `/call answer` | Picks up an incoming call | HFP profile |
| `/say "Hello from your agent"` | Speaks through the phone over BT — generic neural voice | piper |
| `/voice "Hi, this is me"` ⭐ NEW v2.0.0 | Speaks **in your cloned voice** at 48kHz studio quality | VoxCPM2 |
| `/contacts find "John"` | Searches your phone contacts | PBAP profile |
| `/media play / pause / next` | Controls whatever music app is open | AVRCP profile |
| `/file send report.pdf` | Pushes a file to your phone | OBEX |
| `/tether on` | Brings up phone-as-router | PAN/NAP |

Inbound is just as alive — incoming SMS, missed calls, and notifications get bridged to OpenClaw automatically so your agent can react to them in real time.

---

## What is new in v2.0.0

### The agent now sounds like you

Five minutes of you reading six sentences is enough to clone your voice. From that moment on, every voice note your agent sends, every line it speaks over Bluetooth, every reply it dictates back through your phone — all of it goes out in **your** voice.

> The person on the other end of a Telegram voice note hears **you**. Not a stock voice. Not a robot. You.

### Word-level audio splicing

A small but huge detail: voice-cloning models routinely mispronounce unusual names (yours, your spouse, your kids, your dog, your company). Paired solves this by letting you record any specific word once, in your real voice. That clip gets spliced directly into the synthesised output every time the word appears.

**Result: your name is pronounced perfectly, by you, every single time.**

You can have as many splice clips as you like. Family names. Brand names. Place names. Pet names. The agent learns to use them automatically.

### 30 languages, all in your voice

Type in English, Hindi, French, German, Italian, Malay, Spanish, Japanese — and 22 more — the agent speaks them in your cloned voice. Language is detected from input text; no flag needed.

> If you have family abroad, you can now send them voice notes in their language, in your voice, without ever having spoken that language yourself.

### Four-level fallback ladder

The voice path is engineered to not fail.

| Tier | Engine | When it kicks in |
|------|--------|------------------|
| 1 | VoxCPM2 (Apache-2.0) | Cloned voice, 48kHz studio quality |
| 2 | XTTS v2 | Cloned voice, 24kHz, if VoxCPM2 GPU is busy |
| 3 | piper | Generic neural voice, if no cloning service is up |
| 4 | espeak-ng | Robotic last resort, but the reply still ships |

GPU restart? Network blip? Container crash? **The voice note still goes out.**

### Long-form support

The synth wrapper chunks long input at sentence boundaries before calling the cloning engine. Tested up to 2-minute voice notes; scales cleanly to 10+ minutes for dictations, audiobooks, or sermons. Concatenation is seamless — listeners cannot hear the joins.

---

## Why install this skill

### Because you already own the phone

Your phone has a SIM, a carrier, a real number, contacts, message history, a microphone, a speaker, and a screen. Other skills ignore all of that and ask you to pay a third party for a subset of the same features. Paired uses what is already on your desk.

### Because you do not want a robot voice on your behalf

A generic TTS voice on a voice note from "your assistant" is uncanny. A 48kHz clone of **your** voice, with your name pronounced by you, is — strangely — completely natural to the listener. Nobody asks questions.

### Because you care about privacy

Voice reference and splice clips live in `~/.config/paired/voice/`. They are never uploaded. The cloning model runs in a Docker container on your hardware. There is no telemetry, no analytics, no cloud round-trip, and no SaaS account to delete. Pull the plug on the GPU and the voice clone is gone with it.

### Because the agent should reach the world the way you do

Phones are how humans communicate. They have for twenty years. An agent that can only speak inside a chat window is half an agent. Paired gives your agent the same surface area you have — calls, texts, voice notes, notifications, contacts — and then steps out of the way.

---

## Who is this for

* **Home-lab agent builders** running OpenClaw who want a single skill that handles every phone-shaped task
* **Founders and operators** automating personal admin without leaking it into a SaaS pipeline
* **Privacy-first users** who refuse to upload a voice sample to a cloud API
* **Multilingual households** who want one voice across all the languages they speak
* **Researchers** building embodied / agentic phone interactions and tired of stitching ten APIs together
* **Anyone** whose agent should sound like *them*, not like a stock asset

---

## Privacy promises (no asterisks)

- Voice reference, splice clips, contacts, message history — **all stay on your hardware**
- No training data leaves the machine
- Model weights are open-source (Apache-2.0 for VoxCPM2)
- No telemetry, no analytics, no phone-home
- Delete `~/.config/paired/voice/` and you are back to a generic voice; delete `~/.config/paired/` and the skill forgets you entirely

---

## Hardware floor

| Use case | Requirement |
|----------|-------------|
| Full 48kHz cloned voice | NVIDIA GPU with 6GB+ VRAM (modern mid-range card) |
| Cloned voice (24kHz only) | NVIDIA GPU with 4GB VRAM |
| Generic neural voice fallback | CPU only — no GPU required |
| Phone bridge | Android device with ADB-over-Wi-Fi or USB; Bluetooth adapter on host |

There is no minimum subscription. There is no "Pro tier." There is one skill, one license, and your hardware.

---

## Five-minute start

```bash
# 1. Install the skill
clawhub install paired

# 2. Bring up the voice-cloning service
cd skills/paired/skill/engines
docker build -t paired-voxcpm:latest -f voxcpm.Dockerfile .
docker run -d --name paired-voxcpm --gpus all --restart unless-stopped \
  -p 8056:8056 \
  -v ~/.config/paired/voice:/refs \
  paired-voxcpm:latest

# 3. Record your voice (one-time, takes ~5 minutes)
python3 skills/paired/skill/setup/paired-voice-setup.py reference

# 4. (Optional) Record splice clips for tricky words
python3 skills/paired/skill/setup/paired-voice-setup.py word yourname

# 5. Test
python3 skills/paired/skill/voice/paired-voice-synth.py \
  "Hello. This is me speaking through my own agent." /tmp/test.wav
```

That is it. Pair the phone, point the agent at it, and your assistant has a body.

---

## Built on the shoulders of

| Component | License | What it gives us |
|-----------|---------|------------------|
| [OpenBMB/VoxCPM2](https://github.com/OpenBMB/VoxCPM) | Apache-2.0 | 48kHz voice cloning, 30 languages |
| [coqui-tts (idiap fork)](https://github.com/idiap/coqui-ai-TTS) | Apache-2.0 | XTTS v2 fallback engine |
| [rhasspy/piper](https://github.com/rhasspy/piper) | MIT | Generic neural TTS |
| [espeak-ng](https://github.com/espeak-ng/espeak-ng) | GPL-3.0 | Last-resort synth |
| [BlueZ](http://www.bluez.org/) | LGPL-2.1+ | Linux Bluetooth stack |
| [Android Debug Bridge](https://developer.android.com/tools/adb) | Apache-2.0 | Phone control |
| [ffmpeg](https://ffmpeg.org/) | LGPL-2.1+ | Audio plumbing |
| [HuggingFace](https://huggingface.co/) | — | Model hosting |

Every one of these is open source. None of them ask for an API key. Full attribution in [THIRD_PARTY.md](../THIRD_PARTY.md).

Built on the [OpenClaw](https://openclaw.ai) agent framework.

---

## Get started

```bash
clawhub install paired
```

Then read [docs/PAIRING-GUIDE.md](../../docs/PAIRING-GUIDE.md) to pair your phone, and [docs/VOICE-SETUP.md](../docs/VOICE-SETUP.md) to clone your voice.

**Welcome to phone-as-hardware. Welcome to your voice, your number, your agent.**


path: setup/paired-voice-setup.py
content: #!/usr/bin/env python3
"""paired-voice-setup — guided voice-clone training.

Walks the user through recording 60-90 second reference audio plus
optional word-clips (for splicing) in a controlled, scriptable way.

Output:
  ~/.config/paired/voice.conf            (config)
  ~/.config/paired/voice/reference.wav   (combined reference)
  ~/.config/paired/voice/word-clips/*.wav (per-word splice clips)

This skill never bundles or transmits your voice anywhere.
"""
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path


SCRIPT_PROMPTS = [
    {"code": "A1", "text": "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs."},
    {"code": "A2", "text": "A complete sentence is a sentence with a subject, a verb, and a complete thought."},
    {"code": "A3", "text": "I am recording this audio so that an artificial intelligence can learn the sound of my voice."},
    {"code": "A4", "text": "Today is a good day to test my voice. The weather is mild and my microphone is ready."},
    {"code": "A5", "text": "Hello. Thank you. Yes, of course. No, not today. Please. Sorry about that. See you soon."},
    {"code": "A6", "text": "Numbers one two three four five six seven eight nine and ten. The year is twenty twenty six."},
]


CONFIG_DIR = Path.home() / ".config" / "paired"
VOICE_DIR = CONFIG_DIR / "voice"
CLIPS_DIR = VOICE_DIR / "word-clips"
CONFIG_FILE = CONFIG_DIR / "voice.conf"


def ensure_dirs():
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    VOICE_DIR.mkdir(parents=True, exist_ok=True)
    CLIPS_DIR.mkdir(parents=True, exist_ok=True)


def have_tool(name):
    return shutil.which(name) is not None


def record_with_arecord(out_path, seconds):
    cmd = ["arecord", "-q", "-f", "S16_LE", "-r", "24000", "-c", "1",
           "-d", str(seconds), str(out_path)]
    subprocess.run(cmd, check=True)


def normalise(in_path, out_path):
    """Resample to 24kHz mono and loudness-normalise."""
    subprocess.run(
        ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
         "-i", str(in_path),
         "-ar", "24000", "-ac", "1",
         "-af", "loudnorm=I=-16:LRA=11:TP=-1.5",
         str(out_path)],
        check=True,
    )


def reference_setup(args):
    ensure_dirs()
    if not have_tool("arecord"):
        print("ERROR: arecord not found (apt install alsa-utils)")
        sys.exit(1)
    if not have_tool("ffmpeg"):
        print("ERROR: ffmpeg not found")
        sys.exit(1)

    print()
    print("================================================================")
    print("paired voice clone setup — recording 6 phrases for your reference")
    print("================================================================")
    print()
    print("Tips for a clean recording:")
    print("  - Quiet room")
    print("  - Same mic distance for every phrase")
    print("  - Read naturally; don't over-articulate")
    print()

    raw_files = []
    for i, prompt in enumerate(SCRIPT_PROMPTS, 1):
        code = prompt["code"]
        text = prompt["text"]
        print("--- Phrase " + str(i) + "/" + str(len(SCRIPT_PROMPTS)) + " (" + code + ") ---")
        print("Text: " + text)
        input("Press Enter when ready to start recording (about 15 seconds)... ")
        out_raw = VOICE_DIR / ("raw-" + code + ".wav")
        out_norm = VOICE_DIR / ("norm-" + code + ".wav")
        try:
            record_with_arecord(out_raw, 15)
            normalise(out_raw, out_norm)
            raw_files.append(out_norm)
            print("  Saved: " + str(out_norm))
        except subprocess.CalledProcessError as e:
            print("  Recording failed: " + str(e))
            sys.exit(1)
        print()

    list_file = VOICE_DIR / "concat.txt"
    with open(list_file, "w") as f:
        for r in raw_files:
            f.write("file '" + str(r) + "'\n")
    ref = VOICE_DIR / "reference.wav"
    subprocess.run(
        ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
         "-f", "concat", "-safe", "0", "-i", str(list_file),
         "-ar", "24000", "-ac", "1", str(ref)],
        check=True,
    )
    print("Combined reference: " + str(ref))
    print()
    print("Edit ~/.config/paired/voice.conf and set:")
    print("  VOICE_REFERENCE=" + str(ref))
    print()


def word_setup(args):
    ensure_dirs()
    word = args.word.lower().strip()
    if not word.isalnum():
        print("Word must be alphanumeric only")
        sys.exit(1)
    print("Recording 5 takes of '" + word + "' (~2s each). The cleanest becomes the splice clip.")
    print()
    takes = []
    for i in range(1, 6):
        out_raw = CLIPS_DIR / ("raw-" + word + "-" + str(i) + ".wav")
        out_norm = CLIPS_DIR / ("norm-" + word + "-" + str(i) + ".wav")
        input("Take " + str(i) + "/5 — press Enter, say '" + word + "' clearly, wait... ")
        record_with_arecord(out_raw, 3)
        normalise(out_raw, out_norm)
        takes.append(out_norm)
    print()
    print("Listen to each:")
    for i, t in enumerate(takes, 1):
        print("  Take " + str(i) + ": " + str(t))
    chosen = int(input("Which take is cleanest? [1-5]: "))
    final = CLIPS_DIR / (word + ".wav")
    subprocess.run(
        ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
         "-i", str(takes[chosen-1]),
         "-af",
         "silenceremove=start_periods=1:start_silence=0.1:start_threshold=-40dB:"
         "stop_periods=-1:stop_silence=0.1:stop_threshold=-40dB",
         str(final)],
        check=True,
    )
    print("Saved splice clip: " + str(final))


def write_config():
    ensure_dirs()
    if CONFIG_FILE.exists():
        return
    CONFIG_FILE.write_text(
        "# paired voice config (see docs/VOICE-SETUP.md)\n"
        "VOXCPM_URL=http://localhost:8056\n"
        "XTTS_URL=http://localhost:8055\n"
        "VOICE_REFERENCE=" + str(VOICE_DIR / "reference.wav") + "\n"
        "VOICE_REFERENCE_SERVICE=/refs/reference.wav\n"
        "WORD_CLIPS_DIR=" + str(CLIPS_DIR) + "\n"
        "FALLBACK_PIPER_VOICE=en_GB-northern_english_male-medium\n"
    )
    print("Wrote config: " + str(CONFIG_FILE))


def main():
    p = argparse.ArgumentParser()
    sub = p.add_subparsers(dest="cmd", required=True)
    sub.add_parser("init", help="Create config template")
    sub.add_parser("reference", help="Guided reference-audio recording (60-90s)")
    w = sub.add_parser("word", help="Record splice clip for a single word")
    w.add_argument("word")
    args = p.parse_args()

    if args.cmd == "init":
        write_config()
    elif args.cmd == "reference":
        reference_setup(args)
        write_config()
    elif args.cmd == "word":
        word_setup(args)


if __name__ == "__main__":
    main()


path: systemd/bt-agent.service.txt
content: [Unit]
Description=OpenClaw Bluetooth pairing agent
Documentation=file://${HOME}/.openclaw/workspace/skills/bluetooth/SKILL.md
After=bluetooth.service dbus.socket
Requires=dbus.socket

[Service]
Type=simple
ExecStart=%h/bin/bt-agent.py --mode auto
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target


path: systemd/paired-call-watch.service.txt
content: [Unit]
Description=Bluetooth incoming-call watcher (notify via Telegram, no auto-answer)
Documentation=file://${HOME}/bt-skill-expansion/RESUME-HERE.md
After=bluetooth.service ofono.service dbus.service
Wants=bluetooth.service ofono.service
StartLimitIntervalSec=60
StartLimitBurst=6

[Service]
Type=simple
ExecStart=${HOME}/bin/paired-call-watch --watch --hook ${HOME}/bin/paired-call-watch-tg-hook
Restart=on-failure
RestartSec=10
TimeoutStartSec=15
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target


path: systemd/paired-inbox-hook.service.txt
content: [Unit]
Description=Paired \u2014 signed-inbox /sms /phone command dispatcher
Documentation=https://github.com/nj070574-gif/openclaw-paired-skill/blob/main/skill/SKILL.md
After=openclaw.service network.target
Wants=openclaw.service
StartLimitIntervalSec=60
StartLimitBurst=6

[Service]
Type=simple
# Reads commands from ~/.openclaw/paired/inbox/, requires HMAC signature.
# Generates ~/.config/paired/inbox.key once via 'paired-inbox-hook --keygen'.
ExecStart=%h/bin/paired-inbox-hook --watch
Restart=on-failure
RestartSec=10
TimeoutStartSec=15
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target


path: systemd/paired-sms-watch.service.txt
content: [Unit]
Description=Bluetooth SMS/MMS push notification listener (MAP-MNS) -> Telegram
Documentation=file://${HOME}/bt-skill-expansion/RESUME-HERE.md
After=bluetooth.service dbus.service
Wants=bluetooth.service
StartLimitIntervalSec=60
StartLimitBurst=6

[Service]
Type=simple
ExecStart=${HOME}/bin/paired-sms-watch --watch --hook ${HOME}/bin/paired-sms-watch-tg-hook
Restart=on-failure
RestartSec=10
TimeoutStartSec=15
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)