314 lines
12 KiB
Bash
Executable File
314 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# matter-decode.sh — decode a Matter commissioning QR code (MT: URI) from the CLI.
|
|
#
|
|
# This decodes the Matter 84-bit payload (the format a Matter/CHIP commissioning
|
|
# QR encodes) and prints the fields a commissioning app (Apple Home, Google Home,
|
|
# the Matter app) needs to pair with the device.
|
|
#
|
|
# 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<chunkSize; j++ ))
|
|
do
|
|
char=${s:i+j:1}
|
|
idx=${IDX[$char]:-_} # resolve in param-expansion context;
|
|
if [[ $idx == _ ]]; then # avoids an operator-char array key inside $(( ))
|
|
echo "Error: Invalid character: $char" >&2
|
|
return 1
|
|
fi
|
|
value=$(( value + idx * POW38[j] ))
|
|
done
|
|
|
|
for (( j=0; j<byteCount; j++ ))
|
|
do
|
|
BYTES+=( $(( (value >> (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<count; i++ )); do
|
|
bitIndex=$(( start + i ))
|
|
byteIndex=$(( bitIndex / 8 ))
|
|
bitOffset=$(( bitIndex % 8 ))
|
|
|
|
if (( byteIndex < ${#BYTES[@]} ))
|
|
then
|
|
bit=$(( (BYTES[byteIndex] >> 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<len; i++ )); do
|
|
# The reference reverses the digit string; the i-th processed digit is
|
|
# therefore the (len-1-i)-th character of the original.
|
|
digit=${value:len-1-i:1}
|
|
p=$(( (i + 1) % 8 ))
|
|
col=${perm[p * 10 + digit]}
|
|
checksum=${mul[checksum * 10 + col]}
|
|
done
|
|
printf '%s' "${inv[checksum]}"
|
|
}
|
|
|
|
# compute_pairing_code VENDOR PRODUCT FLOW DISCRIMINATOR PASSCODE -> 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 "$@"
|