Created
November 2, 2025 09:57
-
-
Save wizardofozzie/a5f07db5b0862ed3a3898ca4f613641f to your computer and use it in GitHub Desktop.
decode or encode HomeKit uris
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # hkuri.py — encode/decode HomeKit "X-HM://" URIs | |
| import argparse | |
| import json | |
| import re | |
| import string | |
| import sys | |
| BASE36 = string.digits + string.ascii_uppercase | |
| def b36encode(n: int) -> str: | |
| """Encode an integer to a Base36 string.""" | |
| if n == 0: | |
| return "0" | |
| s = [] | |
| while n: | |
| n, r = divmod(n, 36) | |
| s.append(BASE36[r]) | |
| return "".join(reversed(s)) | |
| def b36decode(s: str) -> int: | |
| """Decode a Base36 string to an integer.""" | |
| return int(s.upper(), 36) | |
| def fmt_code(n: int) -> str: | |
| """Format an integer as a setup code (XXX-XX-XXX).""" | |
| code8 = f"{n:08d}" | |
| return f"{code8[:3]}-{code8[3:5]}-{code8[5:]}" | |
| def parse_code(code: str) -> int: | |
| """Parse a setup code string into an integer.""" | |
| d = re.sub(r"\D", "", code) | |
| if not d or len(d) > 8: | |
| raise ValueError("Setup code must be ≤8 digits") | |
| n = int(d) | |
| if n > 0x7FFFFFF: | |
| raise ValueError("Setup code exceeds 27-bit range") | |
| return n | |
| def validate_setup_id(sid: str) -> str: | |
| """Validate the setup ID format.""" | |
| sid = sid.strip().upper() | |
| if not re.fullmatch(r"[A-Z0-9]{4}", sid): | |
| raise ValueError("Setup ID must be 4 chars A–Z/0–9") | |
| return sid | |
| def encode_uri(setup_code: str, category: int, *, setup_id: str, version: int = 0, reserved: int = 0, | |
| flags: int | None = None, wac=False, ble=False, ip=False, paired=False) -> str: | |
| """Encode fields into an X-HM URI.""" | |
| sc = parse_code(setup_code) | |
| if not (0 <= version <= 7): | |
| raise ValueError("Version must be between 0 and 7") | |
| if not (0 <= reserved <= 15): | |
| raise ValueError("Reserved must be between 0 and 15") | |
| if not (0 <= category <= 255): | |
| raise ValueError("Category must be between 0 and 255") | |
| setup_id = validate_setup_id(setup_id) | |
| if flags is None: | |
| flags = (0 | |
| | (0b1000 if wac else 0) | |
| | (0b0100 if ble else 0) | |
| | (0b0010 if ip else 0) | |
| | (0b0001 if paired else 0)) | |
| if not (0 <= flags <= 15): | |
| raise ValueError("Flags must be between 0 and 15") | |
| n = ((version & 0b111) << 43) \ | |
| | ((reserved & 0b1111) << 39) \ | |
| | ((category & 0xFF) << 31) \ | |
| | ((flags & 0b1111) << 27) \ | |
| | (sc & 0x7FFFFFF) | |
| return f"X-HM://{b36encode(n)}{setup_id}" | |
| def decode_uri(uri: str) -> dict: | |
| """Decode an X-HM URI into its components.""" | |
| m = re.match(r'^\s*X-HM://([0-9A-Z]+)\s*$', uri.strip().upper()) | |
| if not m: | |
| raise ValueError("Not an X-HM URI") | |
| payload = m.group(1) | |
| if len(payload) < 5: | |
| raise ValueError("Payload too short") | |
| setup_id = payload[-4:] | |
| n = b36decode(payload[:-4]) | |
| version = (n >> 43) & 0b111 | |
| reserved = (n >> 39) & 0b1111 | |
| category = (n >> 31) & 0xFF | |
| flags = (n >> 27) & 0b1111 | |
| sc = n & 0x7FFFFFF | |
| return { | |
| "version": version, | |
| "reserved": reserved, | |
| "category": category, | |
| "flags_int": flags, | |
| "flags": { | |
| "WAC": bool(flags & 0b1000), | |
| "BLE": bool(flags & 0b0100), | |
| "IP": bool(flags & 0b0010), | |
| "PairedTag": bool(flags & 0b0001), | |
| }, | |
| "setup_code_int": sc, | |
| "setup_code": fmt_code(sc), | |
| "setup_id": setup_id | |
| } | |
| def main(): | |
| """Main function to handle command-line arguments.""" | |
| p = argparse.ArgumentParser(prog="hkuri", description="Encode/Decode HomeKit X-HM URIs") | |
| sub = p.add_subparsers(dest="cmd", required=True) | |
| pe = sub.add_parser("encode", help="Encode fields → X-HM URI") | |
| pe.add_argument("--code", required=True, help="Setup code (e.g. 123-45-678)") | |
| pe.add_argument("--category", required=True, type=int, help="Accessory category (0..255)") | |
| pe.add_argument("--setup-id", required=True, help="4-char SetupID A–Z/0–9") | |
| pe.add_argument("--version", type=int, default=0) | |
| pe.add_argument("--reserved", type=int, default=0) | |
| g = pe.add_mutually_exclusive_group() | |
| g.add_argument("--flags", type=lambda x: int(x, 0), help="Flags int/0b/0x (WAC=8, BLE=4, IP=2, Paired=1)") | |
| gb = pe.add_argument_group("flag bits (ignored if --flags provided)") | |
| gb.add_argument("--wac", action="store_true") | |
| gb.add_argument("--ble", action="store_true") | |
| gb.add_argument("--ip", action="store_true") | |
| gb.add_argument("--paired", action="store_true") | |
| pd = sub.add_parser("decode", help="Decode X-HM URI → fields") | |
| pd.add_argument("uri", nargs="?", help="X-HM://...") | |
| pd.add_argument("--json", action="store_true", help="Output JSON") | |
| pd.add_argument("--stdin", action="store_true", help="Read URIs from stdin (one per line)") | |
| args = p.parse_args() | |
| if args.cmd == "encode": | |
| uri = encode_uri( | |
| setup_code=args.code, | |
| category=args.category, | |
| setup_id=args.setup_id, | |
| version=args.version, | |
| reserved=args.reserved, | |
| flags=args.flags, | |
| wac=getattr(args, "wac", False), | |
| ble=getattr(args, "ble", False), | |
| ip=getattr(args, "ip", False), | |
| paired=getattr(args, "paired", False), | |
| ) | |
| print(uri) | |
| return | |
| if args.cmd == "decode": | |
| def out(obj): | |
| if args.json: | |
| print(json.dumps(obj, separators=(",", ":"))) | |
| else: | |
| print(f"version={obj['version']} reserved={obj['reserved']} category={obj['category']} flags={obj['flags_int']}") | |
| print(f"setup_code={obj['setup_code']} setup_id={obj['setup_id']}") | |
| if args.stdin: | |
| for line in sys.stdin: | |
| s = line.strip() | |
| if not s: | |
| continue | |
| try: | |
| out(decode_uri(s)) | |
| except Exception as e: | |
| print(f"[error] {s}: {e}", file=sys.stderr) | |
| else: | |
| if not args.uri: | |
| raise SystemExit("decode: provide URI or --stdin") | |
| out(decode_uri(args.uri)) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment