feat(doorman): badge listener + decoder TDD + deploy scaffolding [#355]
Modern successor of the 2018 Perl doorman (snapshot in legacy/): bash+coreutils HID decoder, HA webhook dispatch, systemd unit. Offline decoder suite green (9 tests); shellcheck zero-warning. Umbrella #345. Work item: https://projects.knownelement.com/issues/355
This commit is contained in:
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# tests/run-tests.sh — doorman decoder unit tests (offline, no hardware).
|
||||
#
|
||||
# Builds synthetic /dev/input event streams (the exact binary records the
|
||||
# kernel emits) and asserts bin/doorman.sh --selftest decodes them the way
|
||||
# the 2018 Perl doorman did: digits 1-9 = HID usages 30-38, 0 = 39,
|
||||
# Enter = 40, each key arriving as a down/up MSC_SCAN pair that the
|
||||
# parity logic collapses to one digit.
|
||||
#
|
||||
# Usage: bash tests/run-tests.sh
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN="$ROOT/bin/doorman.sh"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
ok() { printf 'ok - %s\n' "$1"; pass=$((pass + 1)); }
|
||||
no() { printf 'FAIL - %s\n' "$1"; fail=$((fail + 1)); }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
hex() { printf '\\x%02x' "$1"; }
|
||||
|
||||
# emit_event <width> <type> <code> <value> — one input_event record, LE.
|
||||
emit_event() {
|
||||
local w="$1" t="$2" c="$3" v="$4" tv
|
||||
if [ "$w" -eq 24 ]; then tv=16; else tv=8; fi
|
||||
# shellcheck disable=SC2046 # %.0s consumes the seq args; emits tv NULs
|
||||
printf '\x00%.0s' $(seq 1 "$tv") > "$TMP/ev.bin"
|
||||
{
|
||||
# type and code are __u16 LE, value is __s32 LE
|
||||
printf '%b' "$(hex $((t & 255)))$(hex $(((t >> 8) & 255)))$(hex $((c & 255)))$(hex $(((c >> 8) & 255)))"
|
||||
printf '%b' "$(hex $((v & 255)))$(hex $(((v >> 8) & 255)))$(hex $(((v >> 16) & 255)))$(hex $(((v >> 24) & 255)))"
|
||||
} >> "$TMP/ev.bin"
|
||||
cat "$TMP/ev.bin"
|
||||
}
|
||||
|
||||
# emit_scan <width> <usage> — one keypress: down + up MSC_SCAN + SYN filler.
|
||||
emit_scan() {
|
||||
emit_event "$1" 4 4 "$2"
|
||||
emit_event "$1" 4 4 "$2"
|
||||
emit_event "$1" 0 0 0
|
||||
}
|
||||
|
||||
# emit_badge <width> <digits...> — each digit + terminating Enter.
|
||||
emit_badge() {
|
||||
local w="$1"; shift
|
||||
local d u
|
||||
for d in "$@"; do
|
||||
if [ "$d" = "ENTER" ]; then u=40; else u=$((29 + 10#$d)); fi
|
||||
emit_scan "$w" "$u"
|
||||
done
|
||||
}
|
||||
|
||||
# emit_digits <width> <digits> — one scan event pair per digit (0 = usage 39,
|
||||
# 1-9 = usages 30-38). Takes the badge string itself, not precomputed usages.
|
||||
emit_digits() {
|
||||
local w="$1" s="$2" i d u
|
||||
for ((i = 0; i < ${#s}; i++)); do
|
||||
d="${s:$i:1}"
|
||||
if [ "$d" = "0" ]; then u=39; else u=$((29 + 10#$d)); fi
|
||||
emit_scan "$w" "$u"
|
||||
done
|
||||
}
|
||||
|
||||
# emit_enter <width> — the Enter terminator keypress.
|
||||
emit_enter() { emit_scan "$1" 40; }
|
||||
|
||||
run_selftest() { # run_selftest <fixture> [width]
|
||||
local w="${2:-24}"
|
||||
"$BIN" --selftest "$1" --width "$w" 2>/dev/null
|
||||
}
|
||||
|
||||
assert_ids() { # assert_ids <desc> <fixture> <expected...> [--width N]
|
||||
local desc="$1" fixture="$2"
|
||||
shift 2
|
||||
local w=24
|
||||
local -a exp=()
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--width" ]; then
|
||||
w="$2"
|
||||
shift 2
|
||||
else
|
||||
exp+=("$1")
|
||||
shift
|
||||
fi
|
||||
done
|
||||
local got expected
|
||||
got="$(run_selftest "$fixture" "$w")"
|
||||
expected="$(printf '%s\n' "${exp[@]}")"
|
||||
if [ "$got" = "$expected" ]; then
|
||||
ok "$desc"
|
||||
else
|
||||
no "$desc"
|
||||
printf ' expected: %s\n' "$(printf '%s | ' "${exp[@]}")"
|
||||
printf ' got: %s\n' "$(printf '%s' "$got" | tr '\n' ' ')"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# T1: two full 10-digit badges + a repeated-digit badge ("11" exercises the
|
||||
# down/up parity state machine on consecutive identical scan codes).
|
||||
f1="$TMP/f1.bin"
|
||||
{
|
||||
emit_digits 24 "0009399422"; emit_enter 24
|
||||
emit_digits 24 "0009106067"; emit_enter 24
|
||||
emit_digits 24 "11"; emit_enter 24
|
||||
} > "$f1"
|
||||
assert_ids "T1 decodes two 10-digit badges + repeated-digit badge" "$f1" \
|
||||
"0009399422" "0009106067" "11"
|
||||
|
||||
# T2: garbage MSC_SCAN usage mid-badge (e.g. media key) must not corrupt
|
||||
# or truncate the scan (2018 behavior: log+skip, id preserved).
|
||||
f2="$TMP/f2.bin"
|
||||
{
|
||||
emit_digits 24 "000"
|
||||
emit_scan 24 42 # garbage usage (backspace) — must be skipped
|
||||
emit_digits 24 "9399422"
|
||||
emit_enter 24
|
||||
} > "$f2"
|
||||
assert_ids "T2 garbage scan usage mid-badge is skipped" "$f2" "0009399422"
|
||||
|
||||
# T3: lone Enter (empty scan) emits nothing.
|
||||
f3="$TMP/f3.bin"
|
||||
emit_enter 24 > "$f3"
|
||||
got="$(run_selftest "$f3")"
|
||||
if [ -z "$got" ]; then ok "T3 lone Enter emits no badge"; else no "T3 lone Enter emits no badge (got: $got)"; fi
|
||||
|
||||
# T4: non-MSC events (EV_KEY type 1) around a badge are ignored.
|
||||
f4="$TMP/f4.bin"
|
||||
{
|
||||
emit_event 24 1 30 1; emit_event 24 1 30 0
|
||||
emit_digits 24 "0"; emit_enter 24
|
||||
emit_event 24 1 30 1; emit_event 24 1 30 0
|
||||
} > "$f4"
|
||||
assert_ids "T4 EV_KEY events ignored, MSC_SCAN decoded" "$f4" "0"
|
||||
|
||||
# T5: truncated trailing record does not hang or corrupt output.
|
||||
f5="$TMP/f5.bin"
|
||||
{
|
||||
emit_digits 24 "7"; emit_enter 24
|
||||
emit_event 24 4 4 39
|
||||
printf '\x00\x00\x00' # 3-byte orphan tail
|
||||
} > "$f5"
|
||||
assert_ids "T5 truncated tail record ignored" "$f5" "7"
|
||||
|
||||
# T6: 16-byte records (32-bit boards, e.g. older Pis) decode identically.
|
||||
f6="$TMP/f6.bin"
|
||||
{ emit_digits 16 "0"; emit_enter 16; } > "$f6"
|
||||
assert_ids "T6 16-byte record width supported" "$f6" "0" --width 16
|
||||
|
||||
# T7: all-zero stream (idle bus) emits nothing.
|
||||
f7="$TMP/f7.bin"
|
||||
for _ in 1 2 3 4 5; do emit_event 24 0 0 0; done > "$f7"
|
||||
got="$(run_selftest "$f7")"
|
||||
if [ -z "$got" ]; then ok "T7 idle all-zero stream emits nothing"; else no "T7 idle stream (got: $got)"; fi
|
||||
|
||||
# T8: missing fixture file fails cleanly, not silently.
|
||||
if "$BIN" --selftest "$TMP/does-not-exist.bin" >/dev/null 2>&1; then
|
||||
no "T8 missing fixture exits nonzero"
|
||||
else
|
||||
ok "T8 missing fixture exits nonzero"
|
||||
fi
|
||||
|
||||
# T9: known 2018 backdoor badges replay end-to-end (regression anchor).
|
||||
f9="$TMP/f9.bin"
|
||||
{
|
||||
emit_digits 24 "0009399422"; emit_enter 24
|
||||
emit_digits 24 "0009106067"; emit_enter 24
|
||||
} > "$f9"
|
||||
assert_ids "T9 2018 backdoor badges decode verbatim" "$f9" \
|
||||
"0009399422" "0009106067"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
printf '\n%d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# tests/shellcheck.sh — enforce shellcheck (via Docker) on all repo shell scripts
|
||||
#
|
||||
# Usage:
|
||||
# bash tests/shellcheck.sh # lint every .sh in the repo
|
||||
# bash tests/shellcheck.sh path/a.sh # lint specific files
|
||||
#
|
||||
# Uses the koalaman/shellcheck:stable image (no native binary required).
|
||||
# Exits non-zero if ANY script emits a diagnostic.
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
IMAGE="koalaman/shellcheck:stable"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
mapfile -t FILES < <(
|
||||
if [ "$#" -gt 0 ]; then
|
||||
for f in "$@"; do
|
||||
f_abs="$(cd "$(dirname "$f")" && pwd)/$(basename "$f")"
|
||||
case "$f_abs" in
|
||||
"$ROOT"/legacy/*) ;; # 2018 Perl snapshot, verbatim, not maintained
|
||||
*) case "$f_abs" in *.sh) echo "$f_abs" ;; esac ;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
while IFS= read -r f_abs; do
|
||||
case "$f_abs" in
|
||||
"$ROOT"/legacy/*) ;; # 2018 Perl snapshot, verbatim, not maintained
|
||||
*) echo "$f_abs" ;;
|
||||
esac
|
||||
done < <(find "$ROOT" -type f -name '*.sh' \
|
||||
-not -path '*/.git/*' -not -path "$ROOT/.crush/*")
|
||||
fi
|
||||
)
|
||||
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||
echo "shellcheck.sh: no .sh files to check." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Filter out non-bash scripts (e.g. PHP with a .sh extension) --------------
|
||||
BASH_FILES=()
|
||||
for f in "${FILES[@]}"; do
|
||||
shebang=$(head -c 64 "$f" 2>/dev/null | head -n1)
|
||||
case "$shebang" in
|
||||
\#!/usr/bin/php* | \#!/usr/bin/env\ php*) ;; # PHP, skip
|
||||
*) BASH_FILES+=("$f") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${#BASH_FILES[@]}" -eq 0 ]; then
|
||||
echo "shellcheck.sh: no bash scripts among targets." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2012 # basename loop is intentional
|
||||
REL=()
|
||||
for f in "${BASH_FILES[@]}"; do REL+=("${f#"$ROOT"/}"); done
|
||||
|
||||
# Intentional conventions of this codebase (not bugs):
|
||||
# SC1090/SC1091 — awk program paths & dynamic sourcing not followable
|
||||
DISABLES=(-e SC1090 -e SC1091)
|
||||
|
||||
echo "Checking ${#BASH_FILES[@]} script(s) with $IMAGE:"
|
||||
printf ' %s\n' "${REL[@]}"
|
||||
|
||||
docker run --rm \
|
||||
-v "$ROOT:/mnt:ro" \
|
||||
-w /mnt \
|
||||
"$IMAGE" -x "${DISABLES[@]}" "${REL[@]}"
|
||||
rc=$?
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "shellcheck: PASS (${#BASH_FILES[@]} scripts clean)"
|
||||
else
|
||||
echo "shellcheck: FAIL (fix the findings above or add targeted disable directives)" >&2
|
||||
fi
|
||||
exit "$rc"
|
||||
Reference in New Issue
Block a user