first commit
This commit is contained in:
@@ -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.
|
||||
Executable
+132
@@ -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
@@ -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"
|
||||
Executable
+161
@@ -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())
|
||||
Executable
+5
@@ -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" "$@"
|
||||
Executable
+107
@@ -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
|
||||
Executable
+26
@@ -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
|
||||
Executable
+72
@@ -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)"
|
||||
Executable
+110
@@ -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"
|
||||
Reference in New Issue
Block a user