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 be3cdd5..c795d69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,5 +66,29 @@ - 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. + +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`. 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 }}