first commit

This commit is contained in:
Mateusz Gruszczyński
2026-08-23 21:34:07 +02:00
commit 1d3dcba1a9
62 changed files with 12456 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Operational scripts
All operator-facing scripts live in this directory.
- `install.sh` — first installation on a Debian/Ubuntu systemd host or LXC container.
- `update.sh` — safe in-place update with SQLite/config/binary backup and automatic rollback on failed health check.
- `service.sh` — start, stop, restart, status, logs and health helper.
- `dev.sh` — development build/run/check workflow.
- `smoke.sh` — HTTP/API smoke test used by `dev.sh --check`.
- `generate_ha_migration.py` — legacy/manual Home Assistant entity mapping helper.
- `install-lxc.sh` — compatibility alias for `install.sh`.
- `common.sh` — shared shell functions; normally not executed directly.
`build.rs` remains in the package root because Cargo requires the build script at that location; it is not an operator script.
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Shared helpers for GREE Controller operational scripts.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SERVICE_NAME="gree-controller.service"
SERVICE_USER="gree-controller"
SERVICE_GROUP="gree-controller"
INSTALL_DIR="/opt/gree-controller"
INSTALL_BINARY="$INSTALL_DIR/gree-controller"
DATA_DIR="/var/lib/gree-controller"
ENV_FILE="/etc/gree-controller.env"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME"
BACKUP_ROOT="/var/backups/gree-controller"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
require_root() {
[[ ${EUID:-$(id -u)} -eq 0 ]] || fail "Run this script as root (for example: sudo $0)."
}
require_systemd() {
command -v systemctl >/dev/null 2>&1 || fail "systemd/systemctl is required for the LXC installation."
}
run_privileged() {
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
fail "Root/sudo privileges are required: $*"
fi
}
install_build_dependencies() {
local missing=0
for cmd in curl cc make pkg-config; do
command -v "$cmd" >/dev/null 2>&1 || missing=1
done
command -v python3 >/dev/null 2>&1 || missing=1
[[ "$missing" -eq 1 ]] || return 0
if command -v apt-get >/dev/null 2>&1; then
say "Installing build dependencies"
run_privileged apt-get update
run_privileged apt-get install -y --no-install-recommends \
build-essential curl ca-certificates pkg-config python3
elif command -v dnf >/dev/null 2>&1; then
say "Installing build dependencies"
run_privileged dnf install -y gcc gcc-c++ make curl ca-certificates pkgconf-pkg-config python3
elif command -v apk >/dev/null 2>&1; then
say "Installing build dependencies"
run_privileged apk add --no-cache build-base curl ca-certificates pkgconf python3
else
fail "Unsupported package manager. Install a C compiler, make, curl, pkg-config, Python 3 and CA certificates."
fi
}
ensure_rust() {
if command -v cargo >/dev/null 2>&1; then
return 0
fi
command -v curl >/dev/null 2>&1 || fail "curl is required to install Rust."
say "Installing stable Rust with rustup"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH"
command -v cargo >/dev/null 2>&1 || fail "Cargo is unavailable after rustup installation."
}
project_version() {
awk '
/^\[package\]/ { package=1; next }
/^\[/ && package { exit }
package && /^version[[:space:]]*=/ {
gsub(/.*=[[:space:]]*"|".*/, "", $0); print; exit
}
' "$PROJECT_ROOT/Cargo.toml"
}
read_env_value() {
local key="$1" default_value="${2:-}" value=""
if [[ -f "$ENV_FILE" ]]; then
value="$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true)"
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
fi
printf '%s' "${value:-$default_value}"
}
health_url() {
local bind port
bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')"
port="${bind##*:}"
printf 'http://127.0.0.1:%s/api/health' "$port"
}
database_path() {
local db
db="$(read_env_value GREE_CONTROLLER_DATABASE "$DATA_DIR/gree-controller.db")"
if [[ "$db" != /* ]]; then
db="$DATA_DIR/$db"
fi
printf '%s' "$db"
}
panel_url() {
local bind port ip
bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')"
port="${bind##*:}"
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
printf 'http://%s:%s' "${ip:-LXC_ADDRESS}" "$port"
}
wait_for_health() {
local url attempts="${1:-60}"
url="$(health_url)"
for _ in $(seq 1 "$attempts"); do
if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then
return 0
fi
sleep 0.5
done
return 1
}
backup_timestamp() {
date -u +'%Y%m%dT%H%M%SZ'
}
Executable
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
cd "$PROJECT_ROOT"
MODE="debug"
ACTION="run"
INSTALL_DEPS=1
RESET_DB=0
HOST=""
PORT=""
usage() {
cat <<'TXT'
GREE Controller - development environment
Usage:
./scripts/dev.sh install missing tools, build and run
./scripts/dev.sh --release run an optimized build
./scripts/dev.sh --check formatting, tests, build and API smoke test
./scripts/dev.sh --reset remove the local database before startup
./scripts/dev.sh --no-install do not install system packages or Rust
./scripts/dev.sh --host 0.0.0.0 --port 8787
TXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--release) MODE="release"; shift ;;
--check) ACTION="check"; shift ;;
--reset) RESET_DB=1; shift ;;
--no-install) INSTALL_DEPS=0; shift ;;
--host) HOST="${2:?missing value for --host}"; shift 2 ;;
--port) PORT="${2:?missing value for --port}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
if [[ "$INSTALL_DEPS" -eq 1 ]]; then
install_build_dependencies
ensure_rust
else
command -v cargo >/dev/null 2>&1 || fail "Cargo is not installed and --no-install was requested."
fi
if [[ ! -f .env ]]; then
cp .env.example .env
say "Created .env from the example configuration"
fi
mkdir -p data
if [[ "$RESET_DB" -eq 1 ]]; then
rm -f data/gree-controller.db data/gree-controller.db-shm data/gree-controller.db-wal
say "Removed the local database"
fi
set -a
# shellcheck disable=SC1091
source ./.env
set +a
if [[ -n "$HOST" || -n "$PORT" ]]; then
current="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
current_host="${current%:*}"
current_port="${current##*:}"
export GREE_CONTROLLER_BIND="${HOST:-$current_host}:${PORT:-$current_port}"
fi
if [[ "$ACTION" == "check" ]]; then
say "Checking formatting"
cargo fmt --all -- --check
say "Running Rust tests"
cargo test --all-targets
say "Building the application"
cargo build
say "Running HTTP/API smoke test"
"$SCRIPT_DIR/smoke.sh"
say "All checks passed"
exit 0
fi
if [[ "$MODE" == "release" ]]; then
say "Building release version"
cargo build --release
BINARY="$PROJECT_ROOT/target/release/gree-controller"
else
say "Building debug version"
cargo build
BINARY="$PROJECT_ROOT/target/debug/gree-controller"
fi
bind="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
display_host="${bind%:*}"
[[ "$display_host" == "0.0.0.0" ]] && display_host="127.0.0.1"
say "Panel: http://${display_host}:${bind##*:}"
say "Stop: Ctrl+C"
exec "$BINARY"
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Generate a Home Assistant entity-ID takeover mapping for GREE Controller."""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
ENTITY_RE = re.compile(r"^climate\.[a-z0-9_]+$")
def request_json(url: str, token: str = "") -> Any:
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
except urllib.error.HTTPError as err:
detail = err.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {err.code}: {detail or err.reason}") from err
except urllib.error.URLError as err:
raise RuntimeError(f"Connection failed: {err.reason}") from err
def normalize_url(value: str) -> str:
value = value.strip().rstrip("/")
parsed = urllib.parse.urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"Invalid URL: {value}")
return value
def parse_mapping(value: str) -> tuple[str, str]:
if "=" not in value:
raise argparse.ArgumentTypeError("mapping must be ENTITY_ID=CONTROLLER_DEVICE_ID")
entity_id, device_id = (part.strip() for part in value.split("=", 1))
if not ENTITY_RE.fullmatch(entity_id):
raise argparse.ArgumentTypeError(f"invalid climate entity ID: {entity_id}")
if not device_id:
raise argparse.ArgumentTypeError("controller device ID cannot be empty")
return entity_id, device_id
def validate_ha_entity(base_url: str, token: str, entity_id: str) -> dict[str, Any]:
encoded = urllib.parse.quote(entity_id, safe="")
state = request_json(f"{base_url}/api/states/{encoded}", token)
if not isinstance(state, dict) or state.get("entity_id") != entity_id:
raise RuntimeError(f"Home Assistant did not return {entity_id}")
return state
def controller_devices(base_url: str, token: str) -> list[dict[str, Any]]:
data = request_json(f"{base_url}/api/integrations/home-assistant/devices", token)
if not isinstance(data, list):
raise RuntimeError("Controller returned an invalid devices response")
return [item for item in data if isinstance(item, dict) and item.get("id")]
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Generate /config/gree_controller_entities.json so the custom Home Assistant "
"integration can claim an existing climate entity ID after the old integration is unloaded."
)
)
parser.add_argument("--map", action="append", default=[], type=parse_mapping, metavar="ENTITY=DEVICE", help="Repeatable mapping, e.g. climate.klima_salon=gree-aabbccddeeff")
parser.add_argument("--entity", help="Single existing HA climate entity, e.g. climate.klima_salon")
parser.add_argument("--device", help="Controller device ID for --entity")
parser.add_argument("--controller-url", help="Optional controller URL used to validate or auto-select a single device")
parser.add_argument("--controller-token", default="", help="GREE Controller Home Assistant access token")
parser.add_argument("--ha-url", help="Optional Home Assistant URL used to validate source entities")
parser.add_argument("--ha-token", default="", help="Optional Home Assistant Long-Lived Access Token")
parser.add_argument("--output", default="home-assistant/generated/gree_controller_entities.json", help="Output mapping file")
args = parser.parse_args()
mappings: list[tuple[str, str]] = list(args.map)
devices: list[dict[str, Any]] = []
controller_url = normalize_url(args.controller_url) if args.controller_url else ""
ha_url = normalize_url(args.ha_url) if args.ha_url else ""
if controller_url:
devices = controller_devices(controller_url, args.controller_token)
if args.entity:
if not ENTITY_RE.fullmatch(args.entity):
parser.error("--entity must be a climate.* entity ID using lowercase letters, digits and underscores")
device_id = (args.device or "").strip()
if not device_id:
if len(devices) == 1:
device_id = str(devices[0]["id"])
elif not controller_url:
parser.error("--device is required unless --controller-url identifies exactly one device")
else:
choices = ", ".join(f"{item['id']} ({item.get('name', 'unnamed')})" for item in devices) or "none"
parser.error(f"--device is required because the controller exposes {len(devices)} devices: {choices}")
mappings.append((args.entity, device_id))
if not mappings:
parser.error("provide at least one --map or --entity")
by_entity: dict[str, str] = {}
by_device: dict[str, str] = {}
for entity_id, device_id in mappings:
if entity_id in by_entity and by_entity[entity_id] != device_id:
parser.error(f"duplicate entity mapping: {entity_id}")
if device_id in by_device and by_device[device_id] != entity_id:
parser.error(f"one controller device cannot claim two climate entity IDs: {device_id}")
by_entity[entity_id] = device_id
by_device[device_id] = entity_id
if devices:
valid_ids = {str(item["id"]) for item in devices}
missing = [device_id for device_id in by_device if device_id not in valid_ids]
if missing:
parser.error(f"controller device ID not found: {', '.join(missing)}")
source_metadata: dict[str, dict[str, Any]] = {}
if ha_url:
if not args.ha_token:
parser.error("--ha-token is required when --ha-url is used")
for entity_id in by_entity:
state = validate_ha_entity(ha_url, args.ha_token, entity_id)
source_metadata[entity_id] = {
"friendly_name": state.get("attributes", {}).get("friendly_name", ""),
"state": state.get("state"),
}
payload = {
"version": 1,
"entities": [
{
"entity_id": entity_id,
"device_id": device_id,
**({"source": source_metadata[entity_id]} if entity_id in source_metadata else {}),
}
for entity_id, device_id in sorted(by_entity.items())
],
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"Generated: {output}")
print("Copy this file to Home Assistant as /config/gree_controller_entities.json.")
print("Before adding GREE Controller in HA, verify the Rust controller can control the AC, then disable/remove the old GREE integration so the requested entity IDs are free.")
print("Automations and dashboards that reference the same entity_id can then continue using it.")
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "install-lxc.sh is kept as a compatibility alias; using scripts/install.sh." >&2
exec "$SCRIPT_DIR/install.sh" "$@"
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
cd "$PROJECT_ROOT"
RUN_TESTS=1
START_SERVICE=1
usage() {
cat <<'TXT'
Install GREE Controller as a systemd service in a Debian/Ubuntu LXC container.
Usage:
sudo ./scripts/install.sh
sudo ./scripts/install.sh --skip-tests
sudo ./scripts/install.sh --no-start
The installer preserves an existing /etc/gree-controller.env file and database.
Use scripts/update.sh for later releases.
TXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-tests) RUN_TESTS=0; shift ;;
--no-start) START_SERVICE=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
require_root
require_systemd
if [[ -x "$INSTALL_BINARY" && -f "$SERVICE_FILE" ]]; then
fail "An existing installation was detected. Use sudo ./scripts/update.sh instead."
fi
install_build_dependencies
ensure_rust
if command -v systemd-detect-virt >/dev/null 2>&1; then
virt="$(systemd-detect-virt --container 2>/dev/null || true)"
[[ -n "$virt" ]] || warn "No container runtime was detected. Installation can still continue on a regular systemd host."
fi
version="$(project_version)"
say "Installing GREE Controller ${version:-unknown}"
if [[ "$RUN_TESTS" -eq 1 ]]; then
say "Running Rust tests before installation"
cargo test --all-targets
fi
say "Building release binary"
cargo build --release
getent group "$SERVICE_GROUP" >/dev/null 2>&1 || groupadd --system "$SERVICE_GROUP"
id "$SERVICE_USER" >/dev/null 2>&1 || \
useradd --system --gid "$SERVICE_GROUP" --home "$DATA_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$DATA_DIR"
install -d -o root -g root -m 0755 "$INSTALL_DIR"
install -d -o root -g root -m 0700 "$BACKUP_ROOT"
install -o root -g root -m 0755 target/release/gree-controller "$INSTALL_BINARY"
install -o root -g root -m 0644 systemd/gree-controller.service "$SERVICE_FILE"
if [[ ! -f "$ENV_FILE" ]]; then
token="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
cat > "$ENV_FILE" <<ENV
GREE_CONTROLLER_BIND=0.0.0.0:8787
GREE_CONTROLLER_DATABASE=$DATA_DIR/gree-controller.db
GREE_CONTROLLER_APP_TOKEN=$token
GREE_CONTROLLER_SIMULATE=true
GREE_CONTROLLER_AUTO_SEED=true
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15
GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=5
GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS=3000
GREE_CONTROLLER_DISCOVERY_BROADCAST=255.255.255.255:7000
GREE_CONTROLLER_ID=gree-controller
RUST_LOG=info,tower_http=info
HA_URL=
HA_TOKEN=
HA_ENTITY_ID=
ENV
chmod 0600 "$ENV_FILE"
say "Generated administrator token and saved it to $ENV_FILE"
printf 'Administrator token: %s\n' "$token"
else
say "Preserving existing $ENV_FILE"
fi
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null
if [[ "$START_SERVICE" -eq 1 ]]; then
say "Starting GREE Controller"
systemctl restart "$SERVICE_NAME"
if ! wait_for_health 80; then
systemctl --no-pager --full status "$SERVICE_NAME" || true
journalctl -u "$SERVICE_NAME" -n 80 --no-pager || true
fail "Service did not pass the health check."
fi
say "Installation complete"
printf 'Panel: %s\n' "$(panel_url)"
printf 'Status: %s status\n' "$SCRIPT_DIR/service.sh"
printf 'Logs: %s logs\n' "$SCRIPT_DIR/service.sh"
else
say "Installation complete; service was not started (--no-start)."
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
action="${1:-status}"
case "$action" in
start|stop|restart)
require_root
systemctl "$action" "$SERVICE_NAME"
;;
status)
systemctl --no-pager --full status "$SERVICE_NAME"
;;
logs)
exec journalctl -u "$SERVICE_NAME" -f
;;
health)
curl -fsS "$(health_url)"; printf '\n'
;;
*)
echo "Usage: $0 {start|stop|restart|status|logs|health}" >&2
exit 2
;;
esac
+72
View File
@@ -0,0 +1,72 @@
#!/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"
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"
# 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)"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
cd "$PROJECT_ROOT"
RUN_TESTS=1
usage() {
cat <<'TXT'
Update an existing systemd/LXC GREE Controller installation from this source tree.
Usage:
sudo ./scripts/update.sh
sudo ./scripts/update.sh --skip-tests
The updater:
1. builds and tests the new release while the old service is still running,
2. stops the service,
3. backs up the binary, systemd unit, environment file and SQLite database,
4. installs the new binary and unit,
5. restarts and checks /api/health,
6. rolls back the binary/unit/database automatically if startup fails.
TXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-tests) RUN_TESTS=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
esac
done
require_root
require_systemd
[[ -x "$INSTALL_BINARY" ]] || fail "No installed binary at $INSTALL_BINARY. Run scripts/install.sh first."
[[ -f "$SERVICE_FILE" ]] || fail "No installed systemd unit at $SERVICE_FILE. Run scripts/install.sh first."
install_build_dependencies
ensure_rust
version="$(project_version)"
say "Preparing update to GREE Controller ${version:-unknown}"
if [[ "$RUN_TESTS" -eq 1 ]]; then
say "Running Rust tests before touching the running service"
cargo test --all-targets
fi
say "Building release binary"
cargo build --release
stamp="$(backup_timestamp)"
backup_dir="$BACKUP_ROOT/$stamp"
install -d -o root -g root -m 0700 "$backup_dir"
db_path="$(database_path)"
rollback() {
local rc=$?
trap - ERR
set +e
warn "Update failed; restoring previous installation from $backup_dir"
systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true
if [[ -f "$backup_dir/gree-controller.binary" ]]; then
install -o root -g root -m 0755 "$backup_dir/gree-controller.binary" "$INSTALL_BINARY"
fi
if [[ -f "$backup_dir/gree-controller.service" ]]; then
install -o root -g root -m 0644 "$backup_dir/gree-controller.service" "$SERVICE_FILE"
fi
if [[ -f "$backup_dir/gree-controller.env" ]]; then
install -o root -g root -m 0600 "$backup_dir/gree-controller.env" "$ENV_FILE"
fi
if [[ -f "$backup_dir/gree-controller.db" ]]; then
install -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0640 "$backup_dir/gree-controller.db" "$db_path"
fi
rm -f "${db_path}-wal" "${db_path}-shm"
systemctl daemon-reload
systemctl start "$SERVICE_NAME" >/dev/null 2>&1 || true
exit "$rc"
}
trap rollback ERR
say "Stopping service for a consistent SQLite backup"
systemctl stop "$SERVICE_NAME"
cp -a "$INSTALL_BINARY" "$backup_dir/gree-controller.binary"
cp -a "$SERVICE_FILE" "$backup_dir/gree-controller.service"
[[ -f "$ENV_FILE" ]] && cp -a "$ENV_FILE" "$backup_dir/gree-controller.env"
if [[ -f "$db_path" ]]; then
cp -a "$db_path" "$backup_dir/gree-controller.db"
fi
say "Backup created: $backup_dir"
say "Installing new release binary"
install -o root -g root -m 0755 target/release/gree-controller "$INSTALL_BINARY.new"
mv -f "$INSTALL_BINARY.new" "$INSTALL_BINARY"
install -o root -g root -m 0644 systemd/gree-controller.service "$SERVICE_FILE"
systemctl daemon-reload
systemctl start "$SERVICE_NAME"
if ! wait_for_health 80; then
journalctl -u "$SERVICE_NAME" -n 100 --no-pager || true
false
fi
trap - ERR
say "Update complete"
printf 'Version: %s\n' "${version:-unknown}"
printf 'Panel: %s\n' "$(panel_url)"
printf 'Backup: %s\n' "$backup_dir"