From 0f6049ab75f205fb7ed38fa8024762809eec3a54 Mon Sep 17 00:00:00 2001 From: George Kiagiadakis Date: Fri, 4 Sep 2026 23:13:00 +0300 Subject: [PATCH 1/2] Rewrite the Supported Profiles section from current sources The list had drifted far from what BlueZ implements. Rebuild it from the bluez and PipeWire trees, with the roles, codecs, specification links and experimental/testing badges the code carries, and record the upstream commits it was verified against. Closes: #7 --- AGENTS.md | 20 +++ assets/css/main.css | 100 ++++++++++++- data/profiles.yaml | 253 ++++++++++++++++++++++++++++++--- layouts/partials/profiles.html | 29 +++- 4 files changed, 369 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be3cdd5..ad56c3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,5 +66,25 @@ - Modules: dark background with subtle blue geometric texture and high-contrast cards. - Profiles/Features/News: light backgrounds with strong readability. +## Supported Profiles Section +`data/profiles.yaml` drives the table. Every field is display data and may be +edited by hand; keep the wording short, since each row renders on two lines. + +- `abbr`, `full`, `roles`, `note`, `url`: the abbreviation, the full name, the + roles the implementation plays, an optional remark of a few words, and the + specification page. +- `version`: what the implementation advertises in its SDP records or version + constants, decoded from hex BCD (0x0104 is 1.4). When the code advertises no + version, it is the version of the specification the code implements. Two + versions joined by " / " mean the roles differ; `roles` says which is which. +- `codecs`: the codecs PipeWire provides for that row, in display order. +- `tag`: `experimental` or `testing`, mirroring the flags on the bluez + registration; the `notes` list at the top explains the badges. +- `verified`: the bluez and PipeWire commits the table was last checked + against. + +Categories render as columns and rows render in file order. Lower-level +building blocks (GAP, L2CAP, SDP, GATT) belong in the `intro`, not in a row. + ## Deployment Reference - GitHub Pages deploy workflow: `.github/workflows/hugo.yml`. diff --git a/assets/css/main.css b/assets/css/main.css index ad2e6a1..32a6a1a 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -570,9 +570,18 @@ ul { padding-right: 5%; } +.profiles-sub p { + margin: 0 0 6px; +} + +.profiles-sub p:last-child { + margin-bottom: 0; +} + .profiles-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); + align-items: start; gap: 16px; } @@ -595,17 +604,94 @@ ul { .profile-table ul { padding-left: 15px; margin: 0 15px; - columns: 2; } .profile-table li { font-size: 14px; color: var(--color-grey); padding-left: 5px; - margin-bottom: 6px; + margin-bottom: 10px; line-height: 130%; } +.profile-name { + display: block; + color: var(--color-black); + font-weight: 600; +} + +.profile-name a { + color: var(--color-dark-blue); + text-decoration: none; + border-bottom: 1px solid rgba(0, 57, 207, 0.35); +} + +.profile-name a:hover, +.profile-name a:focus { + color: var(--color-light-blue); + border-bottom-color: var(--color-light-blue); +} + +.profile-version { + font-weight: 400; + color: var(--color-grey); +} + +.profile-tag { + display: inline-block; + margin-left: 4px; + padding: 1px 6px; + border-radius: 8px; + background: #e0e6f7; + color: var(--color-dark-blue); + font-size: 11px; + font-weight: 600; + letter-spacing: 0; + text-transform: lowercase; + vertical-align: 1px; +} + +.profile-desc { + display: block; + margin-top: 2px; + font-size: 12.5px; + line-height: 140%; + color: #666; +} + +.profiles-notes { + list-style: none; + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 12px; + max-width: 950px; + margin: 24px auto 0; + padding: 0; +} + +.profiles-notes li { + display: contents; +} + +.profiles-notes .profile-tag { + margin-left: 0; + justify-self: start; + align-self: center; + vertical-align: baseline; +} + +.profiles-note-text { + font-size: 13px; + line-height: 155%; + color: #666; +} + +.profiles-notes code { + font-family: "Source Code Pro", Courier, 'Courier New', monospace; + font-size: 12px; + color: var(--color-grey); +} + @media only screen and (max-width: 1400px) { .profiles { padding: 55px 5% 65px; @@ -619,6 +705,9 @@ ul { .profiles-sub { font-size: 17px; } + .profiles-grid { + grid-template-columns: repeat(2, 1fr); + } } @media only screen and (max-width: 750px) { @@ -629,14 +718,11 @@ ul { @media only screen and (max-width: 640px) { .profiles-grid { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: 1fr; } } @media only screen and (max-width: 500px) { - .profiles-grid { - grid-template-columns: 1fr; - } .profiles-sub { font-size: 16px; } diff --git a/data/profiles.yaml b/data/profiles.yaml index bf9b0fc..8323384 100644 --- a/data/profiles.yaml +++ b/data/profiles.yaml @@ -1,29 +1,236 @@ heading: "Supported Profiles" -intro: "**Lower level Host Stack:** Core specification 4.2, Not (yet) 3.0+HS. Includes GAP, L2CAP, RFCOMM and SDP." +intro: | + **Lower level host stack:** GAP, L2CAP (Basic, ERTM and LE credit-based channels), SDP, RFCOMM, ATT/GATT (including Enhanced ATT), SMP and isochronous channels, with support for Core Specification features up to [6.0](https://www.bluetooth.com/specifications/specs/core-specification-6-0/). + + **Audio:** [PipeWire](https://pipewire.org/) provides the endpoints and codecs for A2DP and LE Audio, and implements the HFP and HSP roles. +notes: + - tag: "experimental" + text: "Complete enough to use and built by default, but only registered when experimental profiles are enabled. The D-Bus API it exposes is not yet frozen and may still change." + - tag: "testing" + text: "Work in progress. Intended for development and interoperability testing rather than for general use." +verified: + bluez: "ed3d4c3f91b2a1a73ff8b8ffc6a1e83d34488dfd" + pipewire: "b0b792fa72451fd9a068c1a8f877d21d4c67cd3f" categories: - - name: "Profiles: BlueZ" + - name: "Audio & video (BR/EDR)" items: - - "A2DP 1.3" - - "AVRCP 1.5" - - "DI 1.3" - - "HDP 1.0" - - "HID 1.0" - - "PAN 1.0" - - "SPP 1.1" - - name: "Profiles: GATT (LE)" + - abbr: "A2DP" + version: "1.4" + full: "Advanced Audio Distribution Profile" + roles: "Source, Sink" + codecs: [SBC, AAC, aptX, FastStream, LDAC, LHDC, Opus, Opus 05, LC3plus] + url: "https://www.bluetooth.com/specifications/specs/advanced-audio-distribution-profile-1-4/" + - abbr: "AVRCP" + version: "1.6" + full: "Audio/Video Remote Control Profile" + roles: "Controller, Target" + note: "browsing and cover art" + url: "https://www.bluetooth.com/specifications/specs/a-v-remote-control-profile-1-6-2/" + - abbr: "AVDTP" + version: "1.3" + full: "Audio/Video Distribution Transport Protocol" + roles: "Initiator, Acceptor" + url: "https://www.bluetooth.com/specifications/specs/a-v-distribution-transport-protocol-1-3/" + - abbr: "AVCTP" + version: "1.4" + full: "Audio/Video Control Transport Protocol" + roles: "Initiator, Acceptor" + url: "https://www.bluetooth.com/specifications/specs/a-v-control-transport-protocol-1-4/" + - abbr: "HFP" + version: "1.8" + full: "Hands-Free Profile" + roles: "HF, AG" + codecs: [CVSD, mSBC, LC3-SWB, LC3-24kHz] + note: "built-in HF is experimental; PipeWire implements both roles and advertises 1.9" + url: "https://www.bluetooth.com/specifications/specs/hands-free-profile-1-8/" + - abbr: "HSP" + version: "1.2" + full: "Headset Profile" + roles: "HS, AG" + codecs: [CVSD] + url: "https://www.bluetooth.com/specifications/specs/headset-profile-1-2/" + - name: "LE Audio" items: - - "PXP 1.0" - - "HTP 1.0" - - "TIP 1.0" - - "CSCP 1.0" - - "FMG 1.0" - - name: "Profiles: OBEX" + - abbr: "BAP" + version: "1.0.2" + full: "Basic Audio Profile" + roles: "Unicast Client & Server, Broadcast Source & Sink" + codecs: [LC3] + tag: "experimental" + note: "streaming endpoints provided by PipeWire" + url: "https://www.bluetooth.com/specifications/specs/basic-audio-profile-1-0-2/" + - abbr: "BASS" + version: "1.0" + full: "Broadcast Audio Scan Service" + roles: "Scan Delegator, Broadcast Assistant" + tag: "experimental" + url: "https://www.bluetooth.com/specifications/specs/broadcast-audio-scan-service/" + - abbr: "VCP" + version: "1.0" + full: "Volume Control Profile" + roles: "Controller, Renderer" + tag: "experimental" + url: "https://www.bluetooth.com/specifications/specs/volume-control-profile-1-0/" + - abbr: "MICP" + version: "1.0" + full: "Microphone Control Profile" + roles: "Controller, Device" + tag: "experimental" + url: "https://www.bluetooth.com/specifications/specs/microphone-control-profile-1-0/" + - abbr: "MCP" + version: "1.0.1" + full: "Media Control Profile" + roles: "Client, Server" + tag: "experimental" + url: "https://www.bluetooth.com/specifications/specs/media-control-profile/" + - abbr: "CCP" + version: "1.0" + full: "Call Control Profile" + roles: "Client, Server" + tag: "testing" + url: "https://www.bluetooth.com/specifications/specs/call-control-profile-1-0/" + - abbr: "CSIP" + version: "1.0" + full: "Coordinated Set Identification Profile" + roles: "Set Coordinator, Set Member" + tag: "experimental" + url: "https://www.bluetooth.com/specifications/specs/coordinated-set-identification-profile-1-0-1/" + - abbr: "TMAP" + version: "1.0" + full: "Telephony and Media Audio Profile" + roles: "Client, Server" + tag: "experimental" + url: "https://www.bluetooth.com/specifications/specs/telephony-and-media-audio-profile-1-0/" + - abbr: "GMAP" + version: "1.0" + full: "Gaming Audio Profile" + roles: "Client, Server" + tag: "experimental" + url: "https://www.bluetooth.com/specifications/specs/gaming-audio-profile-1-0/" + - abbr: "ASHA" + full: "Audio Streaming for Hearing Aids" + roles: "Source" + codecs: [G.722] + tag: "experimental" + note: "Google specification, not Bluetooth SIG; streamed by PipeWire" + url: "https://source.android.com/docs/core/connect/bluetooth/asha" + - name: "Input & GATT services" items: - - "FTP 1.3" - - "OPP 1.1" - - "IMAP 1.1" - - "MAP 1.0" - - "PBAP 1.1" - - name: "Profile: oFono project" + - abbr: "HID" + version: "1.1" + full: "Human Interface Device Profile" + roles: "Host" + url: "https://www.bluetooth.com/specifications/specs/human-interface-device-profile-1-1-1/" + - abbr: "HOGP" + version: "1.0" + full: "HID over GATT Profile" + roles: "Host" + url: "https://www.bluetooth.com/specifications/specs/hid-over-gatt-profile-1-0/" + - abbr: "ScPP" + version: "1.0" + full: "Scan Parameters Profile" + roles: "Client" + url: "https://www.bluetooth.com/specifications/specs/scan-parameters-profile-1-0/" + - abbr: "BAS" + version: "1.0" + full: "Battery Service" + roles: "Client" + url: "https://www.bluetooth.com/specifications/specs/battery-service-1-0/" + - abbr: "DIS" + version: "1.1" + full: "Device Information Service" + roles: "Client" + url: "https://www.bluetooth.com/specifications/specs/device-information-service-1-1/" + - abbr: "BLE-MIDI" + version: "1.0" + full: "MIDI over Bluetooth Low Energy" + note: "MIDI Association specification, not Bluetooth SIG; I/O via ALSA sequencer or PipeWire, which can also act as a server" + url: "https://midi.org/midi-over-bluetooth-low-energy-ble-midi" + - name: "Object exchange (OBEX)" items: - - "HFP 1.6 (AG & HF)" + - abbr: "GOEP" + version: "2.0" + full: "Generic Object Exchange Profile" + roles: "Client, Server" + note: "OBEX over L2CAP and RFCOMM, with SRM" + url: "https://www.bluetooth.com/specifications/specs/generic-object-exchange-profile-2-1-1/" + - abbr: "OPP" + version: "1.2" + full: "Object Push Profile" + roles: "Client, Server" + url: "https://www.bluetooth.com/specifications/specs/object-push-profile-1-2-1/" + - abbr: "FTP" + version: "1.3" + full: "File Transfer Profile" + roles: "Client, Server" + url: "https://www.bluetooth.com/specifications/specs/file-transfer-profile-1-3-1/" + - abbr: "PBAP" + version: "1.2 / 1.1" + full: "Phone Book Access Profile" + roles: "Client (PCE) 1.2, Server (PSE) 1.1" + url: "https://www.bluetooth.com/specifications/specs/phone-book-access-profile-1-2-3/" + - abbr: "MAP" + version: "1.4 / 1.0" + full: "Message Access Profile" + roles: "Client (MCE) 1.4, Server (MSE) 1.0" + url: "https://www.bluetooth.com/specifications/specs/message-access-profile-1-4-2/" + - abbr: "BIP" + version: "1.2" + full: "Basic Imaging Profile" + roles: "Client" + note: "AVRCP cover art only" + url: "https://www.bluetooth.com/specifications/specs/basic-imaging-profile-1-2-1/" + - abbr: "SYNCH" + version: "1.0" + full: "Synchronization Profile" + roles: "Client, Server" + note: "IrMC synchronisation" + url: "https://www.bluetooth.com/specifications/specs/synchronization-profile-1-2-1/" + - name: "Networking & serial" + items: + - abbr: "PAN" + version: "1.0" + full: "Personal Area Networking Profile" + roles: "PANU, GN, NAP" + url: "https://www.bluetooth.com/specifications/specs/personal-area-networking-profile-1-0/" + - abbr: "BNEP" + version: "1.0" + full: "Bluetooth Network Encapsulation Protocol" + url: "https://www.bluetooth.com/specifications/specs/bluetooth-network-encapsulation-protocol-1-0/" + - abbr: "SPP" + version: "1.2" + full: "Serial Port Profile" + roles: "Server, Client" + url: "https://www.bluetooth.com/specifications/specs/serial-port-profile-1-2/" + - abbr: "DUN" + version: "1.2" + full: "Dial-Up Networking Profile" + roles: "Server, Client" + note: "via an external profile implementation" + url: "https://www.bluetooth.com/specifications/specs/dial-up-networking-profile-1-2/" + - name: "Other" + items: + - abbr: "DID" + version: "1.3" + full: "Device Identification Profile" + roles: "Server, Client" + url: "https://www.bluetooth.com/specifications/specs/device-id-profile-1-3/" + - abbr: "RAP" + version: "1.0" + full: "Ranging Profile" + roles: "Requestor, Responder" + tag: "experimental" + note: "Channel Sounding based distance measurement" + url: "https://www.bluetooth.com/specifications/specs/ranging-profile-1-0/" + - abbr: "HCRP" + version: "1.2" + full: "Hardcopy Cable Replacement Profile" + roles: "Client" + note: "printing, through the CUPS backend" + url: "https://www.bluetooth.com/specifications/specs/hardcopy-cable-replacement-profile-1-2/" + - abbr: "Mesh" + version: "1.0.1" + full: "Bluetooth Mesh Profile" + roles: "Node, Provisioner" + note: "separate bluetooth-meshd daemon" + url: "https://www.bluetooth.com/specifications/specs/mesh-profile-1-0-1/" diff --git a/layouts/partials/profiles.html b/layouts/partials/profiles.html index e1ee663..45a8c0c 100644 --- a/layouts/partials/profiles.html +++ b/layouts/partials/profiles.html @@ -1,18 +1,41 @@

{{ (hugo.Data).profiles.heading }}

-

{{ (hugo.Data).profiles.intro | markdownify | replaceRE "^

(.*)

\n?$" "$1" | safeHTML }}

+
{{ (hugo.Data).profiles.intro | markdownify }}
{{ range (hugo.Data).profiles.categories }}

{{ .name }}

    - {{ range .items }} -
  • {{ . }}
  • + {{ range $item := .items }} +
  • + + {{ with $item.url }} + {{ $item.abbr }} + {{ else }} + {{ $item.abbr }} + {{ end }} + {{ with $item.version }}{{ . }}{{ end }} + {{ with $item.tag }}{{ . }}{{ end }} + + + {{ $item.full }}{{ with $item.roles }} · {{ . }}{{ end }}{{ with $item.codecs }} · {{ delimit . ", " " and " }} {{ if gt (len .) 1 }}codecs{{ else }}codec{{ end }} from PipeWire{{ end }}{{ with $item.note }} · {{ . }}{{ end }} + +
  • {{ end }}
{{ end }}
+ {{ with (hugo.Data).profiles.notes }} +
    + {{ range . }} +
  • + {{ .tag }} + {{ .text | markdownify | replaceRE "^

    (.*)

    \n?$" "$1" | safeHTML }}
    +
  • + {{ end }} +
+ {{ end }}
From f853cd92f79077285840728ccc4dfc7d033c9757 Mon Sep 17 00:00:00 2001 From: George Kiagiadakis Date: Sat, 5 Sep 2026 00:09:03 +0300 Subject: [PATCH 2/2] Add a skill that keeps the profiles table current An evidence script gathers what the bluez and PipeWire trees register, advertise and build, by pattern searches with path:line citations and without naming any profile. The agent running the skill audits every row and note against that dossier and writes a change list with reasons; a second script validates and applies it and checks the links. The skill ends at a local commit for a person to review and push. --- .agents/skills/update-profiles/SKILL.md | 138 ++++++ .../update-profiles/references/fields.md | 95 ++++ .../skills/update-profiles/scripts/apply.py | 418 ++++++++++++++++++ .../update-profiles/scripts/evidence.py | 369 ++++++++++++++++ .claude/skills/update-profiles | 1 + .gitignore | 1 + AGENTS.md | 4 + 7 files changed, 1026 insertions(+) create mode 100644 .agents/skills/update-profiles/SKILL.md create mode 100644 .agents/skills/update-profiles/references/fields.md create mode 100755 .agents/skills/update-profiles/scripts/apply.py create mode 100755 .agents/skills/update-profiles/scripts/evidence.py create mode 120000 .claude/skills/update-profiles diff --git a/.agents/skills/update-profiles/SKILL.md b/.agents/skills/update-profiles/SKILL.md new file mode 100644 index 0000000..caa7877 --- /dev/null +++ b/.agents/skills/update-profiles/SKILL.md @@ -0,0 +1,138 @@ +--- +name: update-profiles +description: Use when the Supported Profiles table on bluez.org (data/profiles.yaml) needs checking or refreshing against the bluez and PipeWire sources, when someone asks whether the site still matches upstream, or when a new profile, codec or version landed in bluez or PipeWire. +compatibility: Needs python3 with PyYAML, git, hugo, and local checkouts of bluez and PipeWire. Works in any agent that can run shell commands and edit files. +--- + +# Update the Supported Profiles table + +## Overview + +The table is rebuilt from what the code implements, not from memory. Three +parts share the work: `scripts/evidence.py` gathers facts from the source +trees by pattern search, you judge what those facts mean, and +`scripts/apply.py` applies your verdict and checks it. The run ends with a +local commit that a person reviews and pushes. **This skill never pushes and +never opens a pull request.** + +## Steps + +All paths are relative to the repository root. `SKILL` below means this +skill's directory. + +1. **Get the checkouts.** Ask the user for the bluez and PipeWire checkout + paths if they were not given. If there are none, clone them into a + temporary directory outside the repository: + ``` + git clone --filter=blob:none https://github.com/bluez/bluez.git $BLUEZ + git clone --filter=blob:none https://gitlab.freedesktop.org/pipewire/pipewire.git $PIPEWIRE + ``` + Existing checkouts should be on their default branch; run `git pull` in + them unless the user said to use them as they are. Never edit their + files. Report which commit of each you audited against. + +2. **Gather the evidence.** + ``` + python3 SKILL/scripts/evidence.py --bluez $BLUEZ --pipewire $PIPEWIRE --out /tmp/evidence.md + ``` + Read the whole dossier. It cites every finding as `path:line`. + +3. **Audit the table** against the dossier, row by row, using the field + meanings and the method in [references/fields.md](references/fields.md). + The dossier is a starting point, not the truth. When a finding looks odd + or a row has no finding, open the cited file or grep the checkout + yourself before deciding. Note every change you intend, with its reason + and its `path:line` evidence. + +4. **Write the change list** as JSON to a file, in the format below. Nothing + changed? Write `{"summary": "", "changes": [], "unverified": []}`. + +5. **Apply and verify.** + ``` + python3 SKILL/scripts/apply.py apply --data data/profiles.yaml --changes /tmp/changes.json \ + --bluez $BLUEZ --pipewire $PIPEWIRE --report /tmp/report.md + python3 SKILL/scripts/apply.py check --data data/profiles.yaml + hugo --minify + ``` + `apply` validates the list, drops proposed links that do not answer 200, + records the checkout commits under `verified:` and rewrites the file in + its canonical layout. If it rejects the list, fix the list, not the + script. Read `git diff data/profiles.yaml` and confirm it says what you + meant. + +6. **Commit locally** on a new branch. If `git diff` is empty, the table + already matches upstream: make no branch and no commit, and say so, + naming the two commits. Otherwise: + ``` + git checkout -b profiles-update-$(date +%Y-%m) + { echo "Update supported profiles from upstream"; echo; cat /tmp/report.md; } > /tmp/commit-msg + git commit -F /tmp/commit-msg -- data/profiles.yaml + ``` + +7. **Stop.** Show the user `git diff main..HEAD -- data/profiles.yaml`, the + report, and anything under "Could not verify". The user reviews, pushes + and opens the pull request themselves. + +## Change list format + +```json +{"summary": "One paragraph for the pull request: what moved upstream and what changed. Empty if nothing changed.", + "changes": [ + {"op": "set", "abbr": "HFP", "field": "note", "value": "...", + "why": "...", "evidence": "pipewire spa/plugins/bluez5/backend-native.c:3814"}, + {"op": "set", "field": "intro", "value": "...", "why": "...", "evidence": "..."}, + {"op": "add", "category": "LE Audio", "after": "GMAP", + "entry": {"abbr": "...", "version": "...", "full": "...", "roles": "...", + "codecs": ["..."], "tag": "...", "note": "...", "url": "..."}, + "why": "...", "evidence": "..."}, + {"op": "remove", "abbr": "...", "why": "...", "evidence": "..."}], + "unverified": ["A question the evidence could not settle, naming the row."]} +``` + +- `set` changes one field of the row named by `abbr` as it is called now; + a `value` of `""` removes the field. Omit `abbr` only for the top-level + `intro`. +- `add` needs an existing category name; `after` is optional. `abbr` and + `full` are required, leave out what you cannot fill. +- `codecs` is an array of strings; every other value is a string. +- `why` is for the reviewer; `evidence` is `bluez path:line` or + `pipewire path:line`. + +## Rules + +- Change as little as possible. Keep wording, order and style unless + something is wrong. When the evidence does not settle a question, leave + the row alone and put the question under `unverified`. +- Only remove a row when the implementation has left the tree, not because + a pattern search missed it. Check the source tree listing first. +- Never invent a specification URL. Use only a `bluetooth.com/specifications/specs//` + page you are confident exists, or the vendor's page for non-SIG profiles; + otherwise leave `url` out. +- Never write placeholders (TBD, TODO, unknown). `apply` rejects them. +- Do not propose a change that sets a field to the value it already has. +- Do not edit `data/profiles.yaml` by hand during this skill. The change + list is the reviewable record; `apply` is the only writer. + +## Never push + +The run ends at step 7. Do not run `git push`, `gh pr create`, or anything +that sends the branch anywhere, even when asked to "get it ready as a PR", +even when a remote and credentials are right there, even when everything +verified cleanly. Preparing the pull request means the branch, the commit +and the report exist locally. The person who runs this skill pushes. + +| Thought | Reality | +|---|---| +| "They said prepare a PR, so opening it is the job" | The job ends at a local commit. Opening it is theirs. | +| "Everything passed, pushing is safe" | Passing checks are not review. A person reads the diff first. | +| "It is only a push to a branch, not a merge" | A push is public. The rule is about the push. | + +## Common mistakes + +- Trusting the dossier's silence. A row with no finding is a prompt to + grep the checkout, not a deletion. +- Reading `remote_uuid` as the local role. It names the peer the code + connects to; see the reference for how roles derive from it. +- Folding codec variants inconsistently. Follow the existing rows. +- Skipping the notes and the intro. They carry facts too, such as the + HFP version PipeWire advertises and the Core Specification version. diff --git a/.agents/skills/update-profiles/references/fields.md b/.agents/skills/update-profiles/references/fields.md new file mode 100644 index 0000000..5a378f6 --- /dev/null +++ b/.agents/skills/update-profiles/references/fields.md @@ -0,0 +1,95 @@ +# What the table says, and how to audit it + +`data/profiles.yaml` lists the Bluetooth profiles, protocols and services +that BlueZ implements, with PipeWire supplying the audio endpoints, codecs +and the HFP/HSP roles. Categories render as columns, rows in file order. + +## Fields + +- **abbr**: the abbreviation the Bluetooth SIG uses (HFP, BAP, HOGP). A row + is a profile, not a service: a registration named `xyz_profile` under + `profiles/xyz/` is profile XYZ even when the UUID it registers belongs to + the service the profile is built on (`rap_profile` registers `RAS_UUID`, + and the row is RAP, the Ranging Profile, with the profile's role names, + Requestor and Responder). A service gets its own row only when bluez + implements the service without a profile around it (BAS, DIS, BASS). +- **version**: the profile version the implementation advertises, decoded + from hex BCD (`0x0104` is 1.4). Sources, in order of authority: SDP record + tables (the `default_settings`-style tables with `.uuid` and `.version`), + version constants near the profile code (`a2dp_ver`, `AVRCP_CT_VERSION`), + and version fields written into records. When the code advertises different + versions for different roles, write both, e.g. `1.2 / 1.1`, and say in + `roles` which is which. When the implementation advertises no version at + all (most GATT profiles, HID as host, mesh), it is the version of the + specification the code implements, and it changes only when the code + visibly targets a newer one. +- **full**: the profile's full name, without a version. +- **roles**: the roles the implementation plays. Derive them from the code: + - a `struct btd_profile` with `remote_uuid` connects to a peer offering + that UUID, so BlueZ plays the opposite role (remote A2DP Sink means + BlueZ is the Source side of that pairing; remote HFP AG means BlueZ is + the HF); + - `local_uuid`, or an SDP record BlueZ registers, names what BlueZ offers; + - obexd client drivers (`obexd/client/*.c`) are clients, obexd plugins + (`obexd/plugins/*.c`) are servers; + - PipeWire's `enum spa_bt_profile` lists the audio roles it implements + (HFP_HF, HFP_AG, HSP_HS, HSP_AG, A2DP_SINK, A2DP_SOURCE, BAP_*). +- **codecs**: the codecs PipeWire provides for that row. The display name + is the codec's `description` from the dossier. Fold an entry into another + only when its description is that codec's name plus a quality or channel + qualifier: SBC-XQ into SBC, AAC-ELD into AAC, aptX HD and aptX-LL into + aptX, the Opus 05 surround, duplex and pro variants into Opus 05. Different + descriptions are different codecs and stay separate: LC3-SWB and LC3-24kHz + are two entries, not "LC3". The `kind` says which row a codec belongs to: + `MEDIA_CODEC_A2DP` to A2DP, `MEDIA_CODEC_HFP` to HFP (CVSD also to HSP), + `MEDIA_CODEC_BAP` to BAP, `MEDIA_CODEC_ASHA` to ASHA. +- **tag**: `experimental` when the registration sets `experimental = true` + or the code only registers under the experimental D-Bus flag + (`G_DBUS_FLAG_ENABLE_EXPERIMENTAL`); `testing` when it sets + `testing = true`. Otherwise no tag. +- **note**: at most twelve words of context that matters to a user, such + as PipeWire advertising a newer version than bluez, or a profile living + in a separate daemon. Notes must stay true: update or drop them when the + fact they cite changes. +- **url**: the official specification page. + +The **intro** above the table summarises the lower-level stack and credits +PipeWire. The Core Specification version it mentions is the highest one the +bluez code knows about (the dossier lists them). Lower-level building +blocks (GAP, GATT, L2CAP, SDP, RFCOMM) belong there, not in a row. + +The **verified** header records the checkout commits the table was last +checked against. `apply` maintains it; never edit it by hand. + +## Method + +1. Read the "changes since the table was last verified" sections first. + They say where upstream moved. Nothing there means the table probably + still holds, but check every row anyway. +2. For every row, confirm each field against the evidence: versions from + the SDP tables and version constants, roles and tags from the + registrations, codecs from the PipeWire codec list. +3. Look for registrations, SDP records, codecs, obexd drivers, daemons or + directories that no row describes. Each is a candidate row in the + category it belongs to. Bluez's own experimental HFP HF registration is + an example of something deliberately folded into an existing row's + note rather than given a row. +4. Look for rows whose implementation has left the tree. The source tree + listing in the dossier is the check; a pattern search that found + nothing is not. +5. Check that every note and the intro are still true. + +## Worked example + +The dossier shows `pipewire spa/plugins/bluez5/backend-native.c:3814 +version = 0x0109` with the comment `HFP version 1.9`, while the bluez SDP +table advertises `0x0108` for `HFP_HS_UUID` and `HFP_AG_UUID`. The row +keeps `version: "1.8"` (bluez's record) and its note says PipeWire +advertises 1.9. If PipeWire moved to 1.10, the change would be: + +```json +{"op": "set", "abbr": "HFP", "field": "note", + "value": "built-in HF is experimental; PipeWire implements both roles and advertises 1.10", + "why": "PipeWire's native backend now reports HFP 1.10 in its SDP record", + "evidence": "pipewire spa/plugins/bluez5/backend-native.c:3814"} +``` diff --git a/.agents/skills/update-profiles/scripts/apply.py b/.agents/skills/update-profiles/scripts/apply.py new file mode 100755 index 0000000..d40985d --- /dev/null +++ b/.agents/skills/update-profiles/scripts/apply.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""Apply a reviewed change list to data/profiles.yaml. + +The Supported Profiles table is audited by a model that reads an evidence +dossier (see evidence.py) and answers with a JSON change list. This +script is the deterministic half: it validates that list, applies it, records +which upstream commits the table was verified against, rewrites the data file +in one canonical layout and writes a Markdown report for the pull request. + + apply.py apply --data data/profiles.yaml --changes response.txt \ + --bluez DIR --pipewire DIR --report body.md [--skip-urls] + apply.py format --data data/profiles.yaml + apply.py check --data data/profiles.yaml [--urls] + +The change list is a JSON object: + + {"summary": "one paragraph for the pull request", + "changes": [ + {"op": "set", "abbr": "HFP", "field": "note", "value": "...", + "why": "...", "evidence": "pipewire spa/plugins/bluez5/backend-native.c:3812"}, + {"op": "set", "field": "intro", "value": "...", "why": "...", "evidence": "..."}, + {"op": "add", "category": "LE Audio", "after": "GMAP", + "entry": {"abbr": "...", "full": "...", ...}, "why": "...", "evidence": "..."}, + {"op": "remove", "abbr": "HDP", "why": "...", "evidence": "..."}], + "unverified": ["things the model could not settle from the evidence"]} + +Exit status: 0 on success, 1 when the change list is malformed or names rows +that do not exist, 2 when the data file itself is unusable. +""" + +import argparse +import json +import re +import subprocess +import sys +import urllib.request + +import yaml + +ITEM_KEYS = ["abbr", "version", "full", "roles", "codecs", "tag", "note", "url"] +LIST_KEYS = {"codecs"} +TOP_LEVEL_FIELDS = {"intro"} +TAGS = {"experimental", "testing"} +PLACEHOLDER = re.compile(r"\b(TBD|TODO|FIXME|XXX)\b", re.IGNORECASE) +PLAIN_FLOW = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 .+/-]*$") + + +class Malformed(Exception): + pass + + +# --- data file --------------------------------------------------------------- + +def load(path): + try: + with open(path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) + except (OSError, yaml.YAMLError) as exc: + raise SystemExit(f"{path}: cannot read: {exc}") + if not isinstance(data, dict) or not isinstance(data.get("categories"), list): + raise SystemExit(f"{path}: expected a mapping with a categories list") + return data + + +def scalar(value): + return json.dumps(str(value), ensure_ascii=False) + + +def flow_list(values): + return "[" + ", ".join(v if PLAIN_FLOW.match(v) else scalar(v) for v in values) + "]" + + +def emit(data): + out = [] + out.append(f"heading: {scalar(data.get('heading', ''))}") + intro = str(data.get("intro", "")).rstrip("\n") + if "\n" in intro: + out.append("intro: |") + out.extend((" " + line).rstrip() for line in intro.split("\n")) + else: + out.append(f"intro: {scalar(intro)}") + if data.get("notes"): + out.append("notes:") + for note in data["notes"]: + out.append(f" - tag: {scalar(note.get('tag', ''))}") + out.append(f" text: {scalar(note.get('text', ''))}") + if data.get("verified"): + out.append("verified:") + for name in ("bluez", "pipewire"): + if data["verified"].get(name): + out.append(f" {name}: {scalar(data['verified'][name])}") + out.append("categories:") + for category in data["categories"]: + out.append(f" - name: {scalar(category.get('name', ''))}") + out.append(" items:") + for item in category.get("items") or []: + first = True + for key in ITEM_KEYS: + value = item.get(key) + if value in (None, "", []): + continue + prefix = " - " if first else " " + first = False + if key in LIST_KEYS: + out.append(f"{prefix}{key}: {flow_list([str(v) for v in value])}") + else: + out.append(f"{prefix}{key}: {scalar(value)}") + return "\n".join(out) + "\n" + + +def write(path, data): + with open(path, "w", encoding="utf-8") as fh: + fh.write(emit(data)) + + +# --- validation -------------------------------------------------------------- + +def check_data(data): + """Return a list of problems with the data file's shape.""" + problems = [] + seen = set() + for category in data["categories"]: + if not isinstance(category, dict) or not category.get("name"): + problems.append("a category has no name") + continue + for item in category.get("items") or []: + if not isinstance(item, dict): + problems.append(f"{category['name']}: an item is not a mapping") + continue + abbr = item.get("abbr") + if not abbr or not item.get("full"): + problems.append(f"{category['name']}: an item lacks abbr or full: {item}") + continue + if abbr in seen: + problems.append(f"{abbr}: listed twice") + seen.add(abbr) + for key in item: + if key not in ITEM_KEYS: + problems.append(f"{abbr}: unknown field {key}") + if item.get("tag") and item["tag"] not in TAGS: + problems.append(f"{abbr}: tag must be one of {sorted(TAGS)}") + if item.get("codecs") is not None and not isinstance(item["codecs"], list): + problems.append(f"{abbr}: codecs must be a list") + url = item.get("url") + if url and not str(url).startswith("https://"): + problems.append(f"{abbr}: url must start with https://") + return problems + + +def reachable(url, timeout=20): + request = urllib.request.Request(url, headers={"User-Agent": "bluez.org profiles check"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status == 200 + except Exception: + return False + + +def all_urls(data): + for category in data["categories"]: + for item in category.get("items") or []: + if item.get("url"): + yield item["abbr"], item["url"] + + +# --- change list ------------------------------------------------------------- + +def parse_changes(text): + """Accept the model's reply, with or without code fences or chatter.""" + start, end = text.find("{"), text.rfind("}") + if start < 0 or end < start: + raise Malformed("the reply contains no JSON object") + try: + doc = json.loads(text[start:end + 1]) + except json.JSONDecodeError as exc: + raise Malformed(f"the reply is not valid JSON: {exc}") + if not isinstance(doc, dict) or not isinstance(doc.get("changes"), list): + raise Malformed('the reply must be an object with a "changes" list') + for i, change in enumerate(doc["changes"]): + if not isinstance(change, dict): + raise Malformed(f"change {i}: not an object") + op = change.get("op") + if op not in ("set", "add", "remove"): + raise Malformed(f"change {i}: op must be set, add or remove") + for key in ("why", "evidence"): + if not isinstance(change.get(key), str) or not change[key].strip(): + raise Malformed(f"change {i}: {key} must be a non-empty string") + if op == "set": + field = change.get("field") + if change.get("abbr"): + if field not in ITEM_KEYS: + raise Malformed(f"change {i}: field must be one of {ITEM_KEYS}") + elif field not in TOP_LEVEL_FIELDS: + raise Malformed(f"change {i}: without abbr, field must be one of {sorted(TOP_LEVEL_FIELDS)}") + check_value(i, field, change.get("value")) + elif op == "add": + entry = change.get("entry") + if not isinstance(entry, dict) or not isinstance(change.get("category"), str): + raise Malformed(f"change {i}: add needs a category string and an entry object") + for key, value in entry.items(): + if key not in ITEM_KEYS: + raise Malformed(f"change {i}: entry has unknown field {key}") + check_value(i, key, value) + if not entry.get("abbr") or not entry.get("full"): + raise Malformed(f"change {i}: a new entry needs abbr and full") + elif not change.get("abbr"): + raise Malformed(f"change {i}: remove needs an abbr") + unverified = doc.get("unverified") or [] + if not isinstance(unverified, list) or not all(isinstance(u, str) for u in unverified): + raise Malformed('"unverified" must be a list of strings') + doc["unverified"] = unverified + doc["summary"] = doc.get("summary") if isinstance(doc.get("summary"), str) else "" + return doc + + +def check_value(i, field, value): + if value in (None, "", []): + return + if field in LIST_KEYS: + if not isinstance(value, list) or not all(isinstance(v, str) and v.strip() for v in value): + raise Malformed(f"change {i}: {field} must be a list of non-empty strings") + texts = value + else: + if not isinstance(value, str): + raise Malformed(f"change {i}: {field} must be a string") + texts = [value] + for text in texts: + if PLACEHOLDER.search(text): + raise Malformed(f"change {i}: {field} contains placeholder text: {text!r}") + if "\n" in text and field != "intro": + raise Malformed(f"change {i}: {field} must be a single line") + if field == "tag" and value not in TAGS: + raise Malformed(f"change {i}: tag must be one of {sorted(TAGS)}") + if field == "url" and not value.startswith("https://"): + raise Malformed(f"change {i}: url must start with https://") + + +def find_item(data, abbr): + for category in data["categories"]: + items = category.get("items") or [] + for index, item in enumerate(items): + if item.get("abbr") == abbr: + return category, index + raise Malformed(f"no row is called {abbr!r}") + + +def has_item(data, abbr): + return any(item.get("abbr") == abbr + for category in data["categories"] for item in category.get("items") or []) + + +def find_category(data, name): + for category in data["categories"]: + if category.get("name") == name: + return category + raise Malformed(f"no category is called {name!r}") + + +def clean_entry(entry): + return {k: entry[k] for k in ITEM_KEYS if entry.get(k) not in (None, "", [])} + + +def apply_changes(data, doc, check_urls): + """Apply the change list in order. Returns the report lines for each change.""" + lines = [] + for change in doc["changes"]: + op = change["op"] + if op == "set": + field, value = change["field"], change.get("value") + if change.get("abbr"): + category, index = find_item(data, change["abbr"]) + item = category["items"][index] + label = f"{change['abbr']}: {field}" + old = item.get(field) + if field == "url" and value and check_urls and not reachable(value): + lines.append(f"- {label}: proposed {value} but it does not answer 200, kept as is. {change['why']}") + continue + if value in (None, "", []): + item.pop(field, None) + else: + item[field] = value + lines.append(f"- {label}: {fmt(old)} -> {fmt(value)}. {change['why']} ({change['evidence']})") + else: + data[field] = value or "" + lines.append(f"- {field}: rewritten. {change['why']} ({change['evidence']})") + elif op == "add": + category = find_category(data, change["category"]) + entry = clean_entry(change["entry"]) + if has_item(data, entry["abbr"]): + raise Malformed(f"cannot add {entry['abbr']!r}: a row with that name exists") + note = "" + if entry.get("url") and check_urls and not reachable(entry["url"]): + note = f" Its proposed url {entry['url']} does not answer 200 and was dropped." + del entry["url"] + items = category.setdefault("items", []) + position = len(items) + if change.get("after"): + for index, item in enumerate(items): + if item.get("abbr") == change["after"]: + position = index + 1 + items.insert(position, entry) + lines.append(f"- added {entry['abbr']} ({entry['full']}) to {category['name']}. {change['why']} ({change['evidence']}){note}") + else: + category, index = find_item(data, change["abbr"]) + removed = category["items"].pop(index) + lines.append(f"- removed {removed['abbr']} ({removed.get('full', '')}). {change['why']} ({change['evidence']})") + return lines + + +def fmt(value): + if value in (None, "", []): + return "(empty)" + if isinstance(value, list): + return ", ".join(value) + return str(value) + + +def head_commit(path): + try: + return subprocess.run(["git", "-C", path, "rev-parse", "HEAD"], check=True, + capture_output=True, text=True).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return None + + +# --- commands ---------------------------------------------------------------- + +def cmd_format(args): + write(args.data, load(args.data)) + return 0 + + +def cmd_check(args): + data = load(args.data) + problems = check_data(data) + if args.urls: + problems += [f"{abbr}: {url} does not answer 200" for abbr, url in all_urls(data) if not reachable(url)] + for problem in problems: + print(problem) + return 1 if problems else 0 + + +def cmd_apply(args): + data = load(args.data) + problems = check_data(data) + if problems: + raise SystemExit("data file is unusable:\n" + "\n".join(problems)) + try: + with open(args.changes, encoding="utf-8") as fh: + doc = parse_changes(fh.read()) + lines = apply_changes(data, doc, check_urls=not args.skip_urls) + except Malformed as exc: + print(f"change list rejected: {exc}", file=sys.stderr) + return 1 + problems = check_data(data) + if problems: + print("the change list leaves the data file inconsistent:\n" + "\n".join(problems), file=sys.stderr) + return 1 + + verified = dict(data.get("verified") or {}) + for name, path in (("bluez", args.bluez), ("pipewire", args.pipewire)): + commit = head_commit(path) if path else None + if commit: + verified[name] = commit + data["verified"] = verified + write(args.data, data) + + report = [] + if doc["summary"]: + report += [doc["summary"], ""] + report.append(f"Table verified against bluez {verified.get('bluez', '?')[:12]} and PipeWire {verified.get('pipewire', '?')[:12]}.") + report.append("") + report.append("### Changes") + report.append("") + report += lines or ["- none: the table already matched upstream"] + if doc["unverified"]: + report += ["", "### Could not verify", ""] + report += [f"- {item}" for item in doc["unverified"]] + with open(args.report, "w", encoding="utf-8") as fh: + fh.write("\n".join(report) + "\n") + print(f"applied {len(lines)} change(s); report in {args.report}") + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("format", help="rewrite the data file in the canonical layout") + p.add_argument("--data", default="data/profiles.yaml") + p.set_defaults(func=cmd_format) + + p = sub.add_parser("check", help="verify the data file's shape, and optionally its links") + p.add_argument("--data", default="data/profiles.yaml") + p.add_argument("--urls", action="store_true", help="also require every url to answer 200") + p.set_defaults(func=cmd_check) + + p = sub.add_parser("apply", help="apply a change list and write the pull request report") + p.add_argument("--data", default="data/profiles.yaml") + p.add_argument("--changes", required=True, help="file holding the model's reply") + p.add_argument("--report", required=True, help="Markdown report to write") + p.add_argument("--bluez", help="bluez checkout, to record its commit") + p.add_argument("--pipewire", help="PipeWire checkout, to record its commit") + p.add_argument("--skip-urls", action="store_true", help="do not test proposed urls") + p.set_defaults(func=cmd_apply) + + args = parser.parse_args(argv) + try: + return args.func(args) + except SystemExit as exc: + if isinstance(exc.code, str): + print(exc.code, file=sys.stderr) + return 2 + raise + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/update-profiles/scripts/evidence.py b/.agents/skills/update-profiles/scripts/evidence.py new file mode 100755 index 0000000..6172a72 --- /dev/null +++ b/.agents/skills/update-profiles/scripts/evidence.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Collect evidence about the Bluetooth profiles bluez and PipeWire implement. + +Writes a Markdown dossier that a model reads before auditing the Supported +Profiles table (data/profiles.yaml). Nothing here knows any profile by name: +every section is a pattern search or a listing over the whole tree, so it keeps +working when files move or are renamed. Each finding carries a file:line +reference so the model can cite it and a reviewer can follow it. + + evidence.py --bluez DIR --pipewire DIR [--data data/profiles.yaml] [--out evidence.md] + +--data is read only for its `verified:` header, the upstream commits the table +was last checked against, so the dossier can list what changed since then. + +A section that finds nothing says so; a section that fails says why. The +script only exits non-zero when a checkout is missing. +""" + +import argparse +import datetime +import os +import re +import subprocess +import sys + +import yaml + +SKIP_DIRS = {".git", "unit", "emulator", "android", "test", "tests", "doc", "builddir", "build"} +HEX_VERSION = re.compile(r"(?i)(?!=]=|[<>]") +BCD = re.compile(r"0x([0-9a-f]{2})([0-9a-f]{2})", re.IGNORECASE) + + +# --- helpers ----------------------------------------------------------------- + +def walk(root, exts=(".c", ".h"), skip=SKIP_DIRS): + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = sorted(d for d in dirnames if d not in skip) + for name in sorted(filenames): + if name.endswith(exts): + yield os.path.relpath(os.path.join(dirpath, name), root) + + +def read(root, rel): + try: + with open(os.path.join(root, rel), encoding="utf-8", errors="replace") as fh: + return fh.read() + except OSError: + return "" + + +def line_of(text, offset): + return text.count("\n", 0, offset) + 1 + + +def strip_comments(text): + """Blank out C comments, keeping every newline so line numbers stay right.""" + return re.sub(r"/\*.*?\*/", lambda m: "\n" * m.group(0).count("\n"), text, flags=re.DOTALL) + + +def bcd(value): + match = BCD.fullmatch(value) + if not match: + return value + major, minor = int(match.group(1), 16), int(match.group(2), 16) + return f"{value} ({major:x}.{minor:x})" + + +def cap(lines, limit): + if len(lines) <= limit: + return lines + return lines[:limit] + [f"- ... and {len(lines) - limit} more"] + + +def git(root, *args): + try: + return subprocess.run(["git", "-C", root, *args], check=True, + capture_output=True, text=True).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "" + + +def section(title, producer): + try: + lines = producer() + except Exception as exc: # the dossier must come out whatever happens + lines = [f"(could not collect this section: {exc})"] + if not lines: + lines = ["(nothing found)"] + return [f"## {title}", ""] + lines + [""] + + +def initializers(root, needle): + """Yield (rel, line, name, body) for every `struct name = { ... };`.""" + regex = re.compile(r"struct\s+" + needle + r"\s+(\w+)\s*=\s*\{(.*?)\n\s*\};", re.DOTALL) + for rel in walk(root, (".c",)): + text = strip_comments(read(root, rel)) + if needle not in text: + continue + for match in regex.finditer(text): + yield rel, line_of(text, match.start()), match.group(1), match.group(2) + + +def fields(body): + """The scalar designated initializers of a struct body, function pointers left out.""" + out = [] + for name, value in re.findall(r"\.(\w+)\s*=\s*([^,\n]+)", body): + value = value.strip() + if re.fullmatch(r'"[^"]*"|[A-Z][A-Z0-9_]+|true|false|-?\d+|[\w\s|]+\|[\w\s|]+', value) \ + or re.fullmatch(r"[A-Z][A-Z0-9_]+(\s*\|\s*[A-Z][A-Z0-9_]+)*", value): + out.append(f"{name}={value}") + return ", ".join(out) + + +# --- bluez sections ---------------------------------------------------------- + +def bluez_profiles(root): + lines = [] + for rel, line, name, body in initializers(root, "btd_profile"): + if fields(body): + lines.append(f"- {rel}:{line} `{name}`: {fields(body)}") + return lines + + +def bluez_sdp_tables(root): + """Designated-initializer tables that carry a .uuid, with their versions.""" + lines = [] + block = re.compile(r"\{\s*((?:\.\w+\s*=\s*[^,{}]+,?\s*)+)\}", re.DOTALL) + for rel in walk(root, (".c",)): + text = strip_comments(read(root, rel)) + if ".uuid" not in text: + continue + for match in block.finditer(text): + body = match.group(1) + if ".uuid" not in body: + continue + values = dict(re.findall(r"\.(\w+)\s*=\s*([^,\n]+)", body)) + parts = [f"uuid={values.get('uuid', '?').strip()}"] + for key in ("name", "remote_uuid", "version", "priority", "auto_connect", "authorize"): + if key in values: + value = values[key].strip() + parts.append(f"{key}={bcd(value) if key == 'version' else value}") + lines.append(f"- {rel}:{line_of(text, match.start())} " + ", ".join(parts)) + return cap(lines, 80) + + +def bluez_versions(root): + lines = [] + for rel in walk(root): + top = rel.split("/")[0] + if top not in ("profiles", "obexd", "src", "mesh", "plugins"): + continue + for number, text in enumerate(read(root, rel).split("\n"), 1): + match = HEX_VERSION.search(text) + if not match or COMPARISON.search(text.split("0x")[0]) or "#define SDP_ATTR" in text: + continue + lines.append(f"- {rel}:{number} `{text.strip()}` {bcd(match.group(1))}") + return cap(lines, 80) + + +def bluez_gating(root): + lines = [] + trigger = re.compile(r"btd_opts\.(experimental|testing)|kernel_experimental|ENABLE_EXPERIMENTAL|experimental_enabled|testing_enabled") + for rel in walk(root, (".c",)): + if rel.split("/")[0] not in ("profiles", "obexd", "src", "plugins"): + continue + for number, text in enumerate(read(root, rel).split("\n"), 1): + if trigger.search(text): + lines.append(f"- {rel}:{number} `{text.strip()}`") + return cap(lines, 60) + + +def bluez_tree(root): + lines = [] + top = sorted(d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d)) and not d.startswith(".")) + lines.append("- top-level directories: " + " ".join(top)) + by_dir = {} + for rel in walk(root, (".c",)): + directory, name = os.path.split(rel) + if directory.split("/")[0] in ("profiles", "obexd", "plugins", "mesh", "src"): + by_dir.setdefault(directory, []).append(name) + for directory, names in sorted(by_dir.items()): + if directory.startswith(("mesh", "src")): + lines.append(f"- {directory}/: {len(names)} .c files") + else: + lines.append(f"- {directory}/: " + " ".join(names)) + return lines + + +def bluez_build_options(root): + text = read(root, "configure.ac") + lines = [] + for name, flag, helptext in re.findall( + r"AC_ARG_ENABLE\(\[?([\w-]+)\]?,\s*AS_HELP_STRING\(\[([^\]]+)\],\s*\[([^\]]+)\]", text): + lines.append(f"- {flag}: {' '.join(helptext.split())}") + return lines + + +def bluez_core_versions(root): + found = {} + for rel in walk(root): + if rel.split("/")[0] not in ("lib", "monitor", "src", "tools"): + continue + text = read(root, rel) + for match in re.finditer(r'"Bluetooth (\d+\.\d+[a-z]?)"|\{\s*"(\d+\.\d+[a-z]?)",\s*0x[0-9a-f]{2}\s*\}', text): + version = match.group(1) or match.group(2) + found.setdefault(version, f"{rel}:{line_of(text, match.start())}") + + def key(v): + return tuple(int(x) for x in re.findall(r"\d+", v)) + return [f"- {v} ({found[v]})" for v in sorted(found, key=key)] + + +def bluez_uuids(root): + lines = [] + for rel in walk(root, (".h",)): + if not rel.startswith("lib/"): + continue + for name, value in re.findall(r'#define\s+(\w*UUID\w*)\s+"([0-9a-fA-F-]+)"', read(root, rel)): + short = re.fullmatch(r"0000([0-9a-fA-F]{4})-0000-1000-8000-00805f9b34fb", value) + lines.append(f"- {name} = {'0x' + short.group(1) if short else value}") + return cap(sorted(set(lines)), 250) + + +# --- PipeWire sections ------------------------------------------------------- + +def pipewire_plugin_dirs(root): + dirs = set() + for rel in walk(root, (".c",)): + if "struct media_codec" in read(root, rel): + dirs.add(os.path.dirname(rel)) + if not dirs: + for dirpath, dirnames, _ in os.walk(root): + if os.path.basename(dirpath) == "bluez5": + dirs.add(os.path.relpath(dirpath, root)) + return sorted(dirs) + + +def pipewire_tree(root, dirs): + lines = [] + for directory in dirs: + names = sorted(os.listdir(os.path.join(root, directory))) + lines.append(f"- {directory}/: " + " ".join(names)) + return lines + + +def pipewire_codecs(root): + lines = [] + for rel, line, name, body in initializers(root, "media_codec"): + values = dict(re.findall(r"\.(\w+)\s*=\s*([^,\n]+)", body)) + parts = [f"{k}={values[k].strip()}" for k in ("name", "description", "kind", "id", "codec_id", "vendor") if k in values] + lines.append(f"- {rel}:{line} `{name}`: " + ", ".join(parts)) + return lines + + +def pipewire_profiles(root, dirs): + lines = [] + for directory in dirs: + for rel in walk(os.path.join(root, directory)): + path = os.path.join(directory, rel) + text = read(root, path) + rows = text.split("\n") + for number, row in enumerate(rows, 1): + match = HEX_VERSION.search(row) + if match and not COMPARISON.search(row.split("0x")[0]): + comment = "" + for previous in reversed(rows[max(0, number - 4):number - 1]): + if previous.strip().startswith(("/*", "//")): + comment = f" (comment nearby: `{previous.strip()}`)" + break + lines.append(f"- {path}:{number} `{row.strip()}` {bcd(match.group(1))}{comment}") + for match in re.finditer(r"enum\s+spa_bt_profile\s*\{(.*?)\};", text, re.DOTALL): + names = list(dict.fromkeys(re.findall(r"^\s*(SPA_BT_PROFILE_\w+)", match.group(1), re.MULTILINE))) + lines.append(f"- {path}:{line_of(text, match.start())} enum spa_bt_profile: " + " ".join(names)) + return cap(lines, 60) + + +def pipewire_build_options(root): + lines = [] + for name, description in re.findall(r"option\('([^']+)',\s*description:\s*'([^']*)'", read(root, "meson_options.txt")): + if "bluez" in name or "bluetooth" in name: + lines.append(f"- {name}: {description}") + return lines + + +# --- history ----------------------------------------------------------------- + +def head_line(root): + sha = git(root, "rev-parse", "HEAD") + if not sha: + return "not a git checkout" + return git(root, "log", "-1", "--format=%H (%cs) %s") + + +def changes_since(root, since, paths): + if not since or since == "TBD": + return ["(no previous verified commit is recorded; audit the whole table)"] + if not git(root, "rev-parse", "--verify", "--quiet", f"{since}^{{commit}}"): + return [f"(the previously verified commit {since[:12]} is not in this checkout, so no change list is available; audit the whole table)"] + head = git(root, "rev-parse", "HEAD") + if head == since: + return [f"(no new commits since {since[:12]})"] + log = git(root, "log", "--no-merges", "--format=- %h (%cs) %s", f"{since}..HEAD", "--", *paths) + lines = log.split("\n") if log else [] + total = git(root, "rev-list", "--count", f"{since}..HEAD") + header = [f"{len(lines)} of the {total} commits since {since[:12]} touch profile-related paths ({' '.join(paths)}):"] + return header + (cap(lines, 120) if lines else ["- none"]) + + +# --- main -------------------------------------------------------------------- + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--bluez", required=True, help="bluez checkout") + parser.add_argument("--pipewire", required=True, help="PipeWire checkout") + parser.add_argument("--data", default="data/profiles.yaml", help="data file, read for its verified: header") + parser.add_argument("--out", default="evidence.md") + args = parser.parse_args(argv) + + for path in (args.bluez, args.pipewire): + if not os.path.isdir(path): + print(f"{path}: not a directory", file=sys.stderr) + return 2 + + verified = {} + try: + with open(args.data, encoding="utf-8") as fh: + verified = (yaml.safe_load(fh) or {}).get("verified") or {} + except (OSError, yaml.YAMLError): + pass + + bluez, pipewire = args.bluez, args.pipewire + plugin_dirs = pipewire_plugin_dirs(pipewire) + + out = [ + "# Upstream evidence for the Supported Profiles table", + "", + f"Collected {datetime.date.today().isoformat()} by pattern searches over two checkouts.", + f"Findings are cited as `path:line`; bluez paths are relative to the bluez tree, PipeWire paths to the PipeWire tree.", + "", + f"- bluez: {head_line(bluez)}", + f"- PipeWire: {head_line(pipewire)}", + "", + ] + out += section("Upstream changes since the table was last verified: bluez", lambda: changes_since( + bluez, verified.get("bluez"), ["profiles", "src", "obexd", "mesh", "plugins", "lib", "configure.ac"])) + out += section("Upstream changes since the table was last verified: PipeWire", lambda: changes_since( + pipewire, verified.get("pipewire"), plugin_dirs + ["meson_options.txt"])) + out += section("bluez: profile registrations (every `struct btd_profile` initializer)", lambda: bluez_profiles(bluez)) + out += section("bluez: built-in SDP profile records (initializer tables carrying a `.uuid`)", lambda: bluez_sdp_tables(bluez)) + out += section("bluez: other version numbers near profile code (hex BCD, decoded in brackets)", lambda: bluez_versions(bluez)) + out += section("bluez: experimental and testing gates outside the struct flags", lambda: bluez_gating(bluez)) + out += section("bluez: source tree", lambda: bluez_tree(bluez)) + out += section("bluez: build options (configure.ac)", lambda: bluez_build_options(bluez)) + out += section("bluez: Core Specification versions the code knows", lambda: bluez_core_versions(bluez)) + out += section("bluez: UUID names (lib/*.h)", lambda: bluez_uuids(bluez)) + out += section("PipeWire: Bluetooth plugin tree", lambda: pipewire_tree(pipewire, plugin_dirs)) + out += section("PipeWire: codecs (every `struct media_codec` initializer)", lambda: pipewire_codecs(pipewire)) + out += section("PipeWire: profile versions and roles", lambda: pipewire_profiles(pipewire, plugin_dirs)) + out += section("PipeWire: Bluetooth build options (meson_options.txt)", lambda: pipewire_build_options(pipewire)) + + with open(args.out, "w", encoding="utf-8") as fh: + fh.write("\n".join(out)) + print(f"wrote {args.out}: {len(out)} lines") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/update-profiles b/.claude/skills/update-profiles new file mode 120000 index 0000000..664f2e8 --- /dev/null +++ b/.claude/skills/update-profiles @@ -0,0 +1 @@ +../../.agents/skills/update-profiles \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0fcb5b2..6d507cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ public/ # Hugo's build lock .hugo_build.lock +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index ad56c3a..c795d69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,5 +86,9 @@ edited by hand; keep the wording short, since each row renders on two lines. Categories render as columns and rows render in file order. Lower-level building blocks (GAP, L2CAP, SDP, GATT) belong in the `intro`, not in a row. +To refresh the table from upstream, use the `update-profiles` skill in +`.agents/skills/update-profiles/`. It ends at a local commit for a person to +review and push. + ## Deployment Reference - GitHub Pages deploy workflow: `.github/workflows/hugo.yml`.