#!/usr/bin/env bash # # matter-decode.sh — decode a Matter commissioning QR code (MT: URI) from the CLI. # # This is a bash port of the decode logic in matter-qr-decoder.html (the browser # app's camera/QR-scanning feature has no shell equivalent and is intentionally # omitted). It decodes the 84-bit payload and prints the same fields the web UI shows. # # Usage: # ./matter-decode.sh "MT:..." # echo "MT:..." | ./matter-decode.sh # # A companion file manufacturers.tsv (same directory) maps vendor IDs to names. # It is derived from project-chip/connectedhomeip's manufacturers.xml and is # optional: without it, all fields still decode — the vendor name line is simply # skipped. Regenerate it with: python3 gen-manufacturers.py (see that file). # # Two more optional companions, same "missing file = skip the extra" rule: # products.tsv 3 cols: vendor id \t product id \t name (any vendor), # regenerated by the gen-*-products.py scripts. # vendor-equivalents.tsv 2 cols: alias vendor id \t canonical vendor id; # hand-edited to record mergers/acquisitions (e.g. # Feit 5155 == LIFX 4870) so a product resolves under # either id and the vendor name gets annotated. set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)" # Base38 charset, per the Matter spec: 0-9, A-Z, '-', '.' (38 symbols). CHARS=( 0 1 2 3 4 5 6 7 8 9 A B C D E F G H I J K L M N O P Q R S T U V W X Y Z - . ) declare -A IDX # char -> position for (( _i=0; _i<${#CHARS[@]}; _i++ )); do IDX["${CHARS[_i]}"]=$_i; done # Powers of 38 for the 5-char (3-byte) chunk; 38^0..38^4. POW38=( 1 38 1444 54872 2085136 ) BYTES=() # decoded payload as a byte array, little-endian # base38_decode STRING -> fills global BYTES. # Greedy chunking: 5 chars -> 3 bytes, 4 -> 2, 2 -> 1, matching the reference. base38_decode() { local s=$1 local i=0 len=${#s} j value char idx chunkSize byteCount BYTES=() while (( i < len )); do if (( i + 5 <= len )); then chunkSize=5; byteCount=3 elif (( i + 4 <= len )); then chunkSize=4; byteCount=2 elif (( i + 2 <= len )); then chunkSize=2; byteCount=1 else echo "Error: Invalid base38 string length" >&2 return 1 fi value=0 for (( j=0; j&2 return 1 fi value=$(( value + idx * POW38[j] )) done for (( j=0; j> (8*j)) & 0xFF )) ) done i=$(( i + chunkSize )) done } # extract_bits START COUNT -> prints COUNT bits of BYTES, LSB-first, as a number. extract_bits() { local start=$1 count=$2 i result=0 bitIndex byteIndex bitOffset bit for (( i=0; i> bitOffset) & 1 )) result=$(( result | (bit << i) )) fi done printf '%d' "$result" } # get_vendor_name DECID -> prints name if in manufacturers.tsv, else nothing. get_vendor_name() { local tsv="$SCRIPT_DIR/manufacturers.tsv" [[ -f $tsv ]] || return 0 awk -F'\t' -v id="$1" '$1==id { print $2; exit }' "$tsv" } # vendor-equivalents.tsv is 2 columns "alias vendor id \t canonical vendor id" # (see that file's header). It records that two vendor IDs are the same company # -- a merger or acquisition (e.g. Feit Electric 5155 == LIFX 4870). # # equivalent_vendor_ids VENDOR -> prints VENDOR's whole equivalence class: the # vendor itself plus every alias that shares its canonical id, one per line, # unique and sorted. It is the single source of truth for "these vendor IDs are # the same company", used by both the product lookup and the vendor-name # annotation. A missing vendor-equivalents.tsv yields just the vendor itself. equivalent_vendor_ids() { local eq="$SCRIPT_DIR/vendor-equivalents.tsv" local v=$1 root [[ -f $eq ]] || { printf '%s\n' "$v"; return 0; } # canonical id of v: col-2 where v is an alias, else v itself. root=$(awk -F'\t' -v id="$v" '$1 !~ /^#/ && $1==id { print $2; exit }' "$eq") [[ -n $root ]] || root=$v # every alias of that canonical, plus the canonical itself; unique+sorted. { awk -F'\t' -v r="$root" '$1 !~ /^#/ && $2==r { print $1 }' "$eq" printf '%s\n' "$root" } | sort -un } # products.tsv is 3 columns "vendor id \t product id \t name" (regenerated by the # gen-*-products.py scripts; see the "do not edit" note there). # # get_product_name VENDOR PRODUCT -> prints the product name if that pair -- or, # via equivalent_vendor_ids, any equivalent vendor's -- is in products.tsv. The # exact vendor is checked before its equivalents. A missing products.tsv, or an # id not present, yields no name. get_product_name() { local products="$SCRIPT_DIR/products.tsv" [[ -f $products ]] || return 0 local id hit # exact vendor first hit=$(awk -F'\t' -v v="$1" -v p="$2" '$1==v && $2==p { print $3; exit }' "$products") [[ -n $hit ]] && { printf '%s' "$hit"; return 0; } # then any equivalent vendor id, skipping the exact vendor already checked for id in $(equivalent_vendor_ids "$1"); do [[ "$id" == "$1" ]] && continue hit=$(awk -F'\t' -v v="$id" -v p="$2" '$1==v && $2==p { print $3; exit }' "$products") [[ -n $hit ]] && { printf '%s' "$hit"; return 0; } done return 0 } # The Matter "manual pairing code" is the PIN a commissioning app (Apple Home, # Google Home, the Matter app) asks you to type -- it is NOT one of the raw # payload fields, it is derived from the passcode and discriminator and, for # non-standard commissioning flows, the vendor/product ids, then guarded by a # Verhoeff check digit. A port of generateManualPairingCode() from the Matter # QR tool (chill-uk/matter-qr-app, web/app.js). # verhoeff_check_digit DIGITS -> prints the single Verhoeff check digit of the # decimal string DIGITS. The three tables are the fixed standard Verhoeff # tables; the digit string is processed right-to-left, as the reference does. verhoeff_check_digit() { local value=$1 local -a mul=( 0 1 2 3 4 5 6 7 8 9 1 2 3 4 0 6 7 8 9 5 2 3 4 0 1 7 8 9 5 6 3 4 0 1 2 8 9 5 6 7 4 0 1 2 3 9 5 6 7 8 5 9 8 7 6 0 4 3 2 1 6 5 9 8 7 1 0 4 3 2 7 6 5 9 8 2 1 0 4 3 8 7 6 5 9 3 2 1 0 4 9 8 7 6 5 4 3 2 1 0 ) local -a perm=( 0 1 2 3 4 5 6 7 8 9 1 5 7 6 2 8 3 0 9 4 5 8 0 3 7 9 6 1 4 2 8 9 1 6 0 4 3 5 2 7 9 4 5 3 1 2 6 8 7 0 4 2 8 6 5 7 3 9 0 1 2 7 9 3 8 0 6 4 1 5 7 0 4 6 9 1 3 2 5 8 ) local -a inv=( 0 4 3 2 1 5 6 7 8 9 ) local len=${#value} local i p col checksum=0 digit for (( i=0; i prints the # Matter manual pairing code. For standard flow (0) it is 10 digits + a Verhoeff # check digit; user-intent (1) and custom (2) flows append 5-digit vendor and # product groups, so they are 20 digits + check. compute_pairing_code() { local vendor=$1 product=$2 flow=$3 discriminator=$4 passcode=$5 local short has chunk1 chunk2 chunk3 code # 4-bit manual discriminator: the top 4 bits of the 12-bit QR discriminator. short=$(( (discriminator >> 8) & 0x0f )) # Only non-standard flows carry the vendor/product ids in the pairing code. has=$(( flow != 0 ? 1 : 0 )) chunk1=$(( ((short >> 2) & 0x03) | (has << 2) )) chunk2=$(( (passcode & 0x3FFF) | ((short & 0x03) << 14) )) chunk3=$(( (passcode >> 14) & 0x1FFF )) code=$(printf '%1d%05d%04d' "$chunk1" "$chunk2" "$chunk3") if (( has )); then code+=$(printf '%05d%05d' "$vendor" "$product") fi code+=$(verhoeff_check_digit "$code") printf '%s' "$code" } main() { local uri payload uri=${1:-} [[ -z $uri ]] && uri=$(cat) # Trim leading/trailing whitespace uri="${uri#"${uri%%[![:space:]]*}"}" uri="${uri%"${uri##*[![:space:]]}"}" if [[ $uri != MT:* ]]; then echo 'Error: URI must start with "MT:"' >&2 return 1 fi payload=${uri#MT:} [[ -n $payload ]] || { echo 'Error: No data found after "MT:"' >&2; return 1; } base38_decode "$payload" || return 1 local version vendor product customFlow discovery discriminator passcode local cf caps="" vname pname="" others="" on id pairing="" version=$(extract_bits 0 3) vendor=$(extract_bits 3 16) product=$(extract_bits 19 16) customFlow=$(extract_bits 35 2) discovery=$(extract_bits 37 8) discriminator=$(extract_bits 45 12) passcode=$(extract_bits 57 27) case $customFlow in 0) cf="Standard Commissioning Flow" ;; 1) cf="User Intent Commissioning Flow" ;; 2) cf="Custom Commissioning Flow" ;; 3) cf="Reserved" ;; *) cf="Unknown ($customFlow)" ;; esac (( discovery & 0x01 )) && caps="SoftAP" (( discovery & 0x02 )) && caps="${caps:+$caps, }BLE" (( discovery & 0x04 )) && caps="${caps:+$caps, }On IP Network" [[ -z $caps ]] && caps="None" vname=$(get_vendor_name "$vendor") # If the decoded vendor has an equivalent (e.g. Feit 5155 <-> LIFX 4870), # annotate its name so the two read as one company. for id in $(equivalent_vendor_ids "$vendor"); do [[ "$id" == "$vendor" ]] && continue on=$(get_vendor_name "$id") [[ -n $on ]] && others="${others:+$others, }$on" done if [[ -n $others ]]; then if [[ -n $vname ]]; then vname="$vname (a.k.a. $others)" else vname="a.k.a. $others" fi fi # Resolve the product ID to a human name, for any vendor (and equivalents). pname=$(get_product_name "$vendor" "$product") # The pairing code is the PIN a commissioning app asks you to type; it is # derived from the passcode, discriminator, and (for non-standard flows) the # vendor/product ids, with a Verhoeff check digit appended. pairing=$(compute_pairing_code "$vendor" "$product" "$customFlow" "$discriminator" "$passcode") if [ ${#pairing} -eq 11 ] then pairing="${pairing:0:4}-${pairing:4:3}-${pairing:7:4}" fi printf 'Version: %s\n' "$version" printf 'Vendor ID: %s (0x%04X)' "$vendor" "$vendor" [[ -n $vname ]] && printf ' %s' "$vname" printf '\n' printf 'Product ID: %s (0x%04X)' "$product" "$product" [[ -n $pname ]] && printf ' %s' "$pname" printf '\n' printf 'Custom Flow: %s\n' "$cf" printf 'Discovery: %s\n' "$caps" printf 'Discriminator: %s\n' "$discriminator" printf 'Passcode: %s\n' "$passcode" printf 'Pairing Code: %s\n' "$pairing" } main "$@"