Files
gree-controller/scripts/smoke.sh
T

268 lines
15 KiB
Bash
Executable File

#!/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-docs/openapi.json" >"$TMP/openapi.json"
python3 - "$TMP/openapi.json" <<'PYOPENAPI'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
assert doc["openapi"] == "3.1.0"
assert "/api/zones/{id}/control" in doc["paths"]
assert "BearerToken" in doc["components"]["securitySchemes"]
assert doc["servers"][0]["url"] == "/"
PYOPENAPI
curl -fsSL "http://127.0.0.1:$PORT/api-docs" >"$TMP/swagger.html"
grep -qi 'swagger-ui' "$TMP/swagger.html"
curl -fsS "http://127.0.0.1:$PORT/api/bootstrap" >"$TMP/bootstrap.json"
grep -q 'sim-salon' "$TMP/bootstrap.json"
python3 - "$TMP/bootstrap.json" "$PORT" <<'PYBOOTSTRAP'
import json, sys, urllib.request
bootstrap = json.load(open(sys.argv[1], encoding="utf-8"))
port = sys.argv[2]
settings = bootstrap.get("settings")
assert isinstance(settings, dict), "bootstrap.settings missing"
paths = {
"application": "/api/settings/application",
"gree": "/api/settings/gree",
"gree_cloud": "/api/settings/gree-cloud",
"history": "/api/settings/history",
"influxdb": "/api/settings/influxdb",
"notifications": "/api/settings/notifications",
"night": "/api/settings/night",
"home_assistant": "/api/settings/home-assistant",
"debug": "/api/settings/debug",
}
assert set(paths) <= set(settings), f"bootstrap settings sections missing: {set(paths) - set(settings)}"
for section, path in paths.items():
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}") as response:
standalone = json.load(response)
assert settings[section] == standalone, f"bootstrap settings mismatch for {section}"
assert "password" not in settings["influxdb"]
assert "token" not in settings["influxdb"]
assert "password" not in settings["gree_cloud"]
assert "token" not in settings["home_assistant"]
for secret in ("pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"):
assert secret not in settings["notifications"], f"secret field leaked: {secret}"
PYBOOTSTRAP
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 \
"http://127.0.0.1:$PORT/api/devices/sim-salon/probe" >"$TMP/probe.json"
grep -q '"ok":true' "$TMP/probe.json"
grep -q '"response_time_ms":0' "$TMP/probe.json"
curl -fsS "http://127.0.0.1:$PORT/api/history/network?hours=24&limit=100" >"$TMP/network-history.json"
python3 - "$TMP/network-history.json" <<'PYNETWORK'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
assert isinstance(data.get("readings"), list)
assert isinstance(data.get("targets"), list)
assert isinstance(data.get("bucket_seconds"), int) and data["bucket_seconds"] > 0
assert isinstance(data.get("storage"), str) and data["storage"]
PYNETWORK
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" <<EOF
{"name":"Smoke Flow","enabled":true,"description":"smoke","nodes":[{"id":"cond","kind":"constant","x":20,"y":20,"config":{"value":true}},{"id":"action","kind":"zone_thermostat","x":240,"y":20,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"edge","from":"cond","to":"action"}]}
EOF
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-create.json" "http://127.0.0.1:$PORT/api/flows" >"$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/home-assistant" >"$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("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["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/home-assistant" >"$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["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["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/home-assistant")"
[[ "$INVALID_SHARED_STATUS" == "400" ]]
cat >"$TMP/flow-shared-value.json" <<EOF
{"flow":{"name":"Shared value comparisons","enabled":true,"nodes":[{"id":"low","kind":"shared_input","x":20,"y":20,"config":{"input_id":"smoke-shared-outdoor","operator":"lt","value":20}},{"id":"high","kind":"shared_input","x":20,"y":140,"config":{"input_id":"smoke-shared-outdoor","operator":"gt","value":30}},{"id":"either","kind":"logic_or","x":220,"y":80,"config":{}},{"id":"action","kind":"zone_thermostat","x":420,"y":80,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"low","to":"either"},{"id":"e2","from":"high","to":"either"},{"id":"e3","from":"either","to":"action"}]},"overrides":{"low":15,"high":15},"log":false}
EOF
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-shared-value.json" \
"http://127.0.0.1:$PORT/api/flows/simulate" >"$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" <<EOF
{"name":"Native schedule Flow","enabled":true,"nodes":[{"id":"days","kind":"weekday","x":20,"y":20,"config":{"days":[1,2,3,4,5,6,7]}},{"id":"time","kind":"time_range","x":180,"y":20,"config":{"start":"06:00","end":"08:00"}},{"id":"action","kind":"zone_thermostat","x":360,"y":20,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"days","to":"time"},{"id":"e2","from":"time","to":"action"}]}
EOF
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-schedule.json" \
"http://127.0.0.1:$PORT/api/flows" >"$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" <<EOF
{"flow":{"name":"HA fail closed","enabled":true,"nodes":[{"id":"ha","kind":"ha_state","x":20,"y":20,"config":{"entity_id":"binary_sensor.missing","operator":"neq","value":"on"}},{"id":"action","kind":"zone_thermostat","x":260,"y":20,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"ha","to":"action"}]},"log":false}
EOF
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-ha-safe.json" \
"http://127.0.0.1:$PORT/api/flows/simulate" >"$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" <<EOF
{"flow":{"name":"GREE options","enabled":true,"nodes":[{"id":"yes","kind":"constant","x":20,"y":20,"config":{"value":true}},{"id":"action","kind":"device_action","x":260,"y":20,"config":{"device_id":"sim-salon","light":false,"turbo":true,"cooldown_seconds":60}}],"edges":[{"id":"e1","from":"yes","to":"action"}]},"log":false}
EOF
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-device-options.json" \
"http://127.0.0.1:$PORT/api/flows/simulate" >"$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"
# Disabled thermostat zones require an explicit direct-control override on the admin API,
# while the restricted Home Assistant direct-device endpoint stays blocked.
curl -fsS "http://127.0.0.1:$PORT/api/zones/$ZONE_ID" >"$TMP/zone-before-disable.json"
python3 - "$TMP/zone-before-disable.json" "$TMP/zone-disable.json" <<'PYZONE'
import json,sys
zone=json.load(open(sys.argv[1]))
zone["enabled"]=False
json.dump(zone,open(sys.argv[2],"w"))
PYZONE
curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/zone-disable.json" \
"http://127.0.0.1:$PORT/api/zones/$ZONE_ID" >"$TMP/zone-disabled.json"
grep -q '"enabled":false' "$TMP/zone-disabled.json"
BLOCKED_STATUS="$(curl -sS -o "$TMP/blocked-manual.json" -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
-d '{"power":true}' "http://127.0.0.1:$PORT/api/devices/sim-salon/command")"
[[ "$BLOCKED_STATUS" == "400" ]]
grep -q 'manual_override=true' "$TMP/blocked-manual.json"
curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"power":true,"manual_override":true}' \
"http://127.0.0.1:$PORT/api/devices/sim-salon/command" >"$TMP/override-manual.json"
grep -q '"power":true' "$TMP/override-manual.json"
HA_BLOCKED_STATUS="$(curl -sS -o "$TMP/ha-blocked-manual.json" -w '%{http_code}' -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")"
[[ "$HA_BLOCKED_STATUS" == "400" ]]
echo "Smoke test OK (port $PORT)"