#!/usr/bin/env bash set -Eeuo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" BINARY="${GREE_CONTROLLER_TEST_BINARY:-$ROOT/target/debug/gree-controller}" [[ -x "$BINARY" ]] || cargo build >/dev/null TMP="$(mktemp -d)" PORT="${GREE_CONTROLLER_TEST_PORT:-$((19000 + RANDOM % 1000))}" LOG="$TMP/server.log" PID="" cleanup() { if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then kill "$PID" 2>/dev/null || true; wait "$PID" 2>/dev/null || true; fi rm -rf "$TMP" } trap cleanup EXIT GREE_CONTROLLER_BIND="127.0.0.1:$PORT" \ GREE_CONTROLLER_DATABASE="$TMP/test.db" \ GREE_CONTROLLER_SIMULATE=true \ GREE_CONTROLLER_AUTO_SEED=true \ GREE_CONTROLLER_POLL_INTERVAL_SECONDS=2 \ GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=2 \ GREE_CONTROLLER_APP_TOKEN="" \ RUST_LOG=warn \ "$BINARY" >"$LOG" 2>&1 & PID=$! for _ in $(seq 1 80); do if curl -fsS "http://127.0.0.1:$PORT/api/health" >"$TMP/health.json"; then break; fi if ! kill -0 "$PID" 2>/dev/null; then cat "$LOG" >&2; exit 1; fi sleep 0.1 done grep -q '"status":"ok"' "$TMP/health.json" curl -fsS "http://127.0.0.1:$PORT/api/bootstrap" >"$TMP/bootstrap.json" grep -q 'sim-salon' "$TMP/bootstrap.json" curl -fsS -X POST -H 'Content-Type: application/json' \ -d '{"power":true,"mode":"cool","target_temperature":22}' \ "http://127.0.0.1:$PORT/api/devices/sim-salon/command" >"$TMP/command.json" grep -q '"power":true' "$TMP/command.json" curl -fsS -X POST -H 'Content-Type: application/json' \ -d '{"name":"Test","device_id":"sim-salon","enabled":true,"mode":"cool","setpoint":23,"hysteresis":0.6,"min_on_seconds":0,"min_off_seconds":0,"sensor_source":"device"}' \ "http://127.0.0.1:$PORT/api/zones" >"$TMP/zone.json" grep -q '"name":"Test"' "$TMP/zone.json" ZONE_ID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$TMP/zone.json")" sleep 3 curl -fsS "http://127.0.0.1:$PORT/api/history?zone_id=$ZONE_ID&hours=1" >"$TMP/history.json" grep -q '"readings"' "$TMP/history.json" grep -q '"control_temperature"' "$TMP/history.json" curl -fsS "http://127.0.0.1:$PORT/api/readings?device_id=sim-salon&hours=1" >"$TMP/readings.json" grep -q '"readings"' "$TMP/readings.json" # Visual Flow: source graph, dry-run, export/import and optimistic concurrency. cat >"$TMP/flow-create.json" <"$TMP/flow.json" grep -q '"revision":1' "$TMP/flow.json" FLOW_ID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$TMP/flow.json")" FLOW_REVISION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["revision"])' "$TMP/flow.json")" python3 - "$TMP/flow-create.json" "$TMP/flow-simulate.json" "$FLOW_ID" <<'PYFLOW' import json,sys,datetime flow=json.load(open(sys.argv[1])) json.dump({"flow":flow,"flow_id":sys.argv[3],"at":datetime.datetime.now(datetime.timezone.utc).isoformat(),"overrides":{"cond":True},"log":True},open(sys.argv[2],"w")) PYFLOW curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-simulate.json" "http://127.0.0.1:$PORT/api/flows/simulate" >"$TMP/flow-dry-run.json" grep -q '"dry_run":true' "$TMP/flow-dry-run.json" curl -fsS "http://127.0.0.1:$PORT/api/flows/$FLOW_ID/export" >"$TMP/flow-export.json" grep -q '"format":"gree-controller-flow"' "$TMP/flow-export.json" curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-export.json" "http://127.0.0.1:$PORT/api/flows/import" >"$TMP/flow-import.json" grep -q '"revision":1' "$TMP/flow-import.json" python3 - "$TMP/flow-create.json" "$TMP/flow-update.json" "$FLOW_REVISION" <<'PYFLOW' import json,sys flow=json.load(open(sys.argv[1])); flow["expected_revision"]=int(sys.argv[3]); flow["description"]="updated" json.dump(flow,open(sys.argv[2],"w")) PYFLOW curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/flow-update.json" "http://127.0.0.1:$PORT/api/flows/$FLOW_ID" >"$TMP/flow-updated.json" grep -q '"revision":2' "$TMP/flow-updated.json" STALE_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/flow-update.json" "http://127.0.0.1:$PORT/api/flows/$FLOW_ID")" [[ "$STALE_STATUS" == "409" ]] # Shared Flow inputs are source-only; comparisons belong exclusively to Flow references. curl -fsS "http://127.0.0.1:$PORT/api/settings" >"$TMP/settings-shared-input.json" python3 - "$TMP/settings-shared-input.json" "$TMP/settings-shared-input-put.json" <<'PYFLOW' import json,sys settings=json.load(open(sys.argv[1])) items=settings.setdefault("home_assistant",{}).setdefault("flow_inputs",[]) items=[item for item in items if item.get("id") != "smoke-shared-outdoor"] items.append({"id":"smoke-shared-outdoor","name":"Smoke outdoor value","kind":"outdoor_temperature","config":{}}) settings["home_assistant"]["flow_inputs"]=items json.dump(settings,open(sys.argv[2],"w")) PYFLOW curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-put.json" \ "http://127.0.0.1:$PORT/api/settings" >"$TMP/settings-shared-input-result.json" python3 - "$TMP/settings-shared-input-result.json" <<'PYFLOW' import json,sys settings=json.load(open(sys.argv[1])) item=next(item for item in settings["home_assistant"]["flow_inputs"] if item["id"] == "smoke-shared-outdoor") assert item["config"] == {}, item PYFLOW python3 - "$TMP/settings-shared-input-put.json" "$TMP/settings-shared-input-invalid.json" <<'PYFLOW' import json,sys settings=json.load(open(sys.argv[1])) item=next(item for item in settings["home_assistant"]["flow_inputs"] if item["id"] == "smoke-shared-outdoor") item["config"]={"operator":"lt","value":20} json.dump(settings,open(sys.argv[2],"w")) PYFLOW INVALID_SHARED_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-invalid.json" "http://127.0.0.1:$PORT/api/settings")" [[ "$INVALID_SHARED_STATUS" == "400" ]] cat >"$TMP/flow-shared-value.json" <"$TMP/flow-shared-value-result.json" python3 - "$TMP/flow-shared-value-result.json" <<'PYFLOW' import json,sys result=json.load(open(sys.argv[1])) action=result["actions"][0] assert action["matched"] is True, result trace={item["node_id"]:item for item in action["trace"]} assert trace["low"]["matched"] is True and trace["low"]["expected"] == 20, trace assert trace["high"]["matched"] is False and trace["high"]["expected"] == 30, trace PYFLOW # A schedule-compatible Flow must keep using the native schedule engine. cat >"$TMP/flow-schedule.json" <"$TMP/flow-schedule-result.json" python3 - "$TMP/flow-schedule-result.json" <<'PYFLOW' import json,sys flow=json.load(open(sys.argv[1])) assert len(flow["compiled_schedule_ids"]) == 1, flow assert len(flow["compiled_automation_ids"]) == 0, flow PYFLOW # HA errors must fail closed: even a != condition cannot become true just because HA is unavailable. cat >"$TMP/flow-ha-safe.json" <"$TMP/flow-ha-safe-result.json" python3 - "$TMP/flow-ha-safe-result.json" <<'PYFLOW' import json,sys result=json.load(open(sys.argv[1])) action=result["actions"][0] assert action["matched"] is False, result assert action["would_execute"] is False, result PYFLOW # Direct GREE Flow actions use the existing DeviceCommand surface beyond power/mode/target. cat >"$TMP/flow-device-options.json" <"$TMP/flow-device-options-result.json" grep -q '"would_execute":true' "$TMP/flow-device-options-result.json" # Home Assistant gets its own generated, restricted controller token. curl -fsS -X POST -H 'Content-Type: application/json' \ -d '{"name":"Smoke Home Assistant"}' \ "http://127.0.0.1:$PORT/api/access-tokens" >"$TMP/access-token.json" HA_ACCESS_TOKEN="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["token"])' "$TMP/access-token.json")" [[ "$HA_ACCESS_TOKEN" == gree_controller_* ]] if curl -fsS "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices" >/dev/null 2>&1; then echo "Restricted Home Assistant API unexpectedly accepted a request without a token" >&2 exit 1 fi curl -fsS -H "Authorization: Bearer $HA_ACCESS_TOKEN" \ "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices" >"$TMP/ha-devices.json" grep -q 'sim-salon' "$TMP/ha-devices.json" curl -fsS -X POST -H "Authorization: Bearer $HA_ACCESS_TOKEN" -H 'Content-Type: application/json' \ -d '{"power":false}' \ "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices/sim-salon/command" >"$TMP/ha-command.json" grep -q '"power":false' "$TMP/ha-command.json" echo "Smoke test OK (port $PORT)"