#!/usr/bin/env bash set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" state_home="$(mktemp -d /tmp/panama-control-center-state.XXXXXX)" source_config_path="$repo_dir/config/dot/quickshell" config_path="$state_home/quickshell" helper_log="$state_home/home-helper.log" shell_log="$state_home/quickshell.log" : >"$helper_log" cp -a "$source_config_path" "$config_path" cat >"$config_path/scripts/panama-home-assistant" <<'EOF' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >>"$PANAMA_HOME_HELPER_LOG" case "${1:-}" in catalog) printf '%s\n' '{"ok":false,"error":"test-helper"}' ;; toggle|brightness) printf '%s\n' '{"ok":true}' ;; esac EOF chmod +x "$config_path/scripts/panama-home-assistant" fail() { printf 'Control Center services contract: %s\n' "$1" >&2 exit 1 } qs_for_test() { QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ PANAMA_HOME_HELPER_LOG="$helper_log" \ qs -p "$config_path" "$@" } stop_test_shell() { qs_for_test kill >/dev/null 2>&1 || true for _ in $(seq 1 80); do if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \ && ! qs_for_test ipc show >/dev/null 2>&1; then return 0 fi sleep 0.1 done return 1 } cleanup() { qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true qs_for_test ipc call status-events reset >/dev/null 2>&1 || true qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true if stop_test_shell; then rm -rf "$state_home" else printf 'Control Center services contract: branch shell did not stop; retained %s\n' \ "$state_home" >&2 fi } trap cleanup EXIT start_test_shell() { stop_test_shell || fail 'pre-existing branch shell did not stop cleanly' for _attempt in 1 2; do qs_for_test --daemonize >"$shell_log" 2>&1 for _ in $(seq 1 80); do if qs_for_test ipc show 2>/dev/null | rg '^target home-assistant$' >/dev/null; then return fi sleep 0.1 done stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly' done sed -n '1,200p' "$shell_log" >&2 fail 'isolated branch shell did not start' } wait_for_home_status() { local filter="$1" local message="$2" local status="" for _ in $(seq 1 80); do status="$(qs_for_test ipc call home-assistant status)" if jq -e "$filter" <<<"$status" >/dev/null; then return fi sleep 0.1 done printf 'Last Home status: %s\n' "$status" >&2 fail "$message" } start_test_shell qs_for_test ipc show | rg '^target kdeconnect$' >/dev/null \ || fail 'KDE Connect IPC target is missing' qs_for_test ipc show | rg '^target home-assistant$' >/dev/null \ || fail 'Home Assistant IPC target is missing' qs_for_test ipc call kdeconnect fixture reachable >/dev/null jq -e ' .fixture == true and .available == true and .reachable == true and .actionCount == 4 and .transferActive == false and .ongoingCount == 0 ' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'reachable phone fixture is malformed' qs_for_test ipc call kdeconnect fixture offline >/dev/null jq -e ' .fixture == true and .available == true and .reachable == false and .pairedCount == 1 and .ongoingCount == 0 ' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'offline phone fixture is malformed' qs_for_test ipc call kdeconnect fixture transfer >/dev/null jq -e ' .fixture == true and .transferActive == true and .transferFileName == "Fixture document.pdf" and .ongoingCount == 1 ' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'phone transfer did not enter Ongoing' qs_for_test ipc call kdeconnect cancel >/dev/null jq -e '.transferActive == false and .ongoingCount == 0' \ <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \ || fail 'phone transfer did not leave Ongoing' qs_for_test ipc call home-assistant fixture ready >/dev/null home_ready="$(qs_for_test ipc call home-assistant status)" jq -e ' .fixture == true and .phase == "ready" and .discoveredCount == 7 and .configuredCount == 7 and .visibleCount == 4 and .selectedIds[0:4] == [ "light.fixture_all", "light.fixture_kitchen", "light.fixture_living", "light.fixture_bedroom" ] and (.entities[0:4] | map(.name)) == [ "Whole home", "Kitchen island", "Living room", "Bedroom" ] and .stale == false and .busy == false and .lastError == "" ' <<<"$home_ready" >/dev/null \ || fail 'ready Home fixture is malformed' qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null jq -e ' .entities[] | select(.id == "light.fixture_living") | .active == true and .state == "on" and .brightnessPct == 64 ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home brightness fixture did not update local catalog state' qs_for_test ipc call home-assistant toggle light.fixture_living >/dev/null jq -e ' .entities[] | select(.id == "light.fixture_living") | .active == false and .state == "off" ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home toggle fixture did not update local catalog state' qs_for_test ipc call home-assistant fixture process-actions >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null jq -e ' .busy == true and .busyEntityIds == ["light.fixture_living", "light.fixture_hall"] and .pendingBrightness["light.fixture_living"] == 64 ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'process-backed Home actions did not remain per-entity busy' wait_for_home_status ' .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and (.entities[] | select(.id == "light.fixture_living") | .active == true and .brightnessPct == 64) and (.entities[] | select(.id == "light.fixture_hall") | .active == true) ' 'process-backed Home actions did not complete in queue order' qs_for_test ipc call home-assistant fixture process-no-output >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_kitchen >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null wait_for_home_status ' .phase == "ready" and .busy == false and .busyEntityIds == [] and .entityErrors["light.fixture_kitchen"] == "action-failed" and (.entityErrors["light.fixture_hall"] // "") == "" and (.entities[] | select(.id == "light.fixture_hall") | .active == true) ' 'nonzero no-output Home action did not fail safely and continue the queue' qs_for_test ipc call home-assistant fixture process-actions >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null jq -e ' .busyEntityIds == ["light.fixture_living"] and .pendingBrightness["light.fixture_living"] == 88 ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'fixture transition setup did not start an in-flight action' qs_for_test ipc call home-assistant fixture process-no-output >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null wait_for_home_status ' .phase == "ready" and .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and .entityErrors == {} and (.entities[] | select(.id == "light.fixture_living") | .active == false and .brightnessPct == 36) and (.entities[] | select(.id == "light.fixture_hall") | .active == true) ' 'switching process fixtures stranded or misattributed the new action' qs_for_test ipc call home-assistant fixture process-delayed-exit >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null wait_for_home_status ' .busyEntityIds == ["light.fixture_living"] and .pendingBrightness["light.fixture_living"] == 88 and .actionProcessRunning == false and .actionStreamFinished == true ' 'nested fixture transition setup did not reach its delayed-exit window' qs_for_test ipc call home-assistant fixture process-no-output >/dev/null qs_for_test ipc call home-assistant fixture process-slow-actions >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null jq -e ' .fixture == true and .fixtureTransitionDraining == true and .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and .entityErrors == {} and .queuedActionCount == 0 and .actionActive == false and .actionProcessRunning == false and (.entities[] | select(.id == "light.fixture_desk") | .active == true and .brightnessPct == 24) ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'nested fixture transition accepted an action during its drain' wait_for_home_status ' .fixture == true and .fixtureTransitionDraining == false and .busy == false and .queuedActionCount == 0 and .actionActive == false and .actionProcessRunning == false ' 'nested fixture transition did not install the latest stable fixture' qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null wait_for_home_status ' .phase == "ready" and .fixtureTransitionDraining == false and .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and .entityErrors == {} and .queuedActionCount == 0 and .actionActive == false and (.entities[] | select(.id == "light.fixture_living") | .active == false and .brightnessPct == 36) and (.entities[] | select(.id == "light.fixture_desk") | .active == true and .brightnessPct == 57) ' 'nested fixture transitions released the drain or corrupted the latest action' qs_for_test ipc call home-assistant fixture process-delayed-exit >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null wait_for_home_status ' .fixture == true and .busyEntityIds == ["light.fixture_living"] and .pendingBrightness["light.fixture_living"] == 88 and .actionProcessRunning == false and .actionStreamFinished == true ' 'reset-to-live setup did not reach its process drain window' qs_for_test ipc call home-assistant reset >/dev/null qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null qs_for_test ipc call home-assistant brightness light.fixture_living 63 >/dev/null jq -e ' .fixture == true and .fixtureTransitionDraining == true and .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and .entityErrors == {} and .queuedActionCount == 0 and .actionActive == false and .actionProcessRunning == false ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'reset-to-live drain accepted an action from the old fixture' wait_for_home_status ' .fixture == false and .fixtureTransitionDraining == false and .busy == false and .busyEntityIds == [] and .pendingBrightness == {} and .entityErrors == {} and .queuedActionCount == 0 and .actionActive == false and .actionProcessRunning == false ' 'reset-to-live left stale fixture action state or started a replacement process' if rg '^(toggle|brightness)( |$)' "$helper_log" >&2; then fail 'reset-to-live issued a stale fixture action to the live helper' fi qs_for_test ipc call home-assistant fixture missing-selected >/dev/null jq -e ' .fixture == true and .phase == "ready" and .discoveredCount == 7 and .configuredCount == 8 and (.entities[] | select(.id == "light.fixture_missing") | .sourceName == "fixture missing" and .name == "Porch" and .state == "unavailable" and .available == false and .active == false and .dimmable == false and .brightnessPct == 0) ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'missing Home selection was not retained as unavailable' qs_for_test ipc call home-assistant fixture action-error >/dev/null jq -e ' .fixture == true and .phase == "ready" and .stale == false and .entityErrors["light.fixture_kitchen"] == "request-failed" and (.entityErrors["light.fixture_hall"] // "") == "" and .lastError == "" ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'Home action error was not isolated to one entity' qs_for_test ipc call home-assistant fixture stale >/dev/null jq -e ' .fixture == true and .phase == "degraded" and .discoveredCount == 7 and .configuredCount == 7 and .visibleCount == 4 and .stale == true and .lastError == "unreachable" ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'stale Home fixture did not retain entities' qs_for_test ipc call home-assistant fixture unavailable >/dev/null jq -e ' .fixture == true and .phase == "unavailable" and .discoveredCount == 0 and .configuredCount == 0 and .visibleCount == 0 and .lastError == "not-configured" ' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \ || fail 'unavailable Home fixture is malformed' trap - EXIT cleanup [[ ! -e "$state_home" ]] \ || fail 'temporary Home preferences state was not removed after shell exit' printf 'Control Center services contract: PASS\n'