This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 22:52:13 +02:00
1 parent f376f0a7b6
commit 8059e16d12
36 files changed
+1140 -193

No files matched your search

+4
View File
@@ -18,3 +18,7 @@ All operator-facing scripts live in this directory.
- `network-debug.sh <interface> [broadcast-ip]` — prints interface/routing diagnostics and a tcpdump command.
The running controller uses only the committed `web/styles.css` embedded into the Rust binary. There is no frontend dependency installation, CDN, or CSS build step.
- `verify_simple_heating.py` — static validation for the three simple-heating Flow imports, language files and release versions.
- `test_simple_heating_ui.js` — checks automatic Flow vs manual thermostat statuses.
- `test_flow_block_help_ui.js` — checks the localized two-block descriptions, dynamically calculated day/night heating examples and the Flow inspector help panel.
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Developer smoke/integration tests for GREE Controller API 0.16.1.
"""Developer smoke/integration tests for GREE Controller API 0.16.2.
Default mode is read-only and safe to run against a real controller.
Use --settings-write to additionally round-trip all split settings resources and
@@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional
DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "")
DEFAULT_EXPECTED_VERSION = "0.16.1"
DEFAULT_EXPECTED_VERSION = "0.16.2"
SETTINGS_PATHS: dict[str, set[str]] = {
"/api/settings/application": {"simulator_enabled"},
@@ -599,7 +599,7 @@ def run_suite(args: argparse.Namespace) -> int:
print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n")
runner.run("public health", lambda: test_health(client, args.expected_version))
runner.run("OpenAPI 0.16.1 contract", lambda: test_openapi(client, args.expected_version))
runner.run("OpenAPI 0.16.2 contract", lambda: test_openapi(client, args.expected_version))
runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client))
runner.run("protected API requires auth", lambda: test_protected_auth(client))
+95
View File
@@ -0,0 +1,95 @@
// Verify contextual help for the constant and room heating Flow blocks.
// Does not need a browser or a running HVAC controller.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const translations = {};
for (const language of ['pl', 'en']) {
translations[language] = JSON.parse(fs.readFileSync(path.join(root, 'lang', `${language}.json`), 'utf8')).translations;
}
function localize(language, key, params = {}) {
const result = translations[language][key];
assert.ok(result, `missing translation ${language}:${key}`);
return String(result).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
}
const app = {
zones: [{ id: 'zone1', name: 'Pokój' }],
devices: [], groups: [], flowDraft: null, flowSelectedNodeId: null,
};
const host = {
innerHTML: '',
expandedNode: null,
classList: { toggle() {}, remove() {} },
querySelector(selector) {
return selector === '.flow-block-help' && this.expandedNode
? { open: true, dataset: { flowHelpNode: this.expandedNode } } : null;
},
};
const ctx = vm.createContext({
app,
tr: (key, params) => localize(ctx.language, key, params),
language: 'pl',
esc: value => String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;'),
fmtTemp: value => `${Number(value).toFixed(1)}°C`,
uiIcon: () => '',
$: selector => selector === '#flowInspector' ? host : null,
document: { addEventListener() {} }, window: { addEventListener() {} },
});
vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/flows.js'), 'utf8'), ctx);
const meta = vm.runInContext('FLOW_NODE_META', ctx);
assert.equal(meta.constant.descriptionKey, 'flow.constantDescription');
assert.equal(meta.zone_heat_cycle.descriptionKey, 'flow.simpleHeatDescription');
for (const language of ['pl', 'en']) {
ctx.language = language;
for (const file of fs.readdirSync(path.join(root, 'flows/simple-heating'))) {
if (!file.endsWith('.flow.json')) continue;
const json = JSON.parse(fs.readFileSync(path.join(root, 'flows/simple-heating', file), 'utf8'));
const setting = json.flow.nodes.find(node => node.kind === 'zone_heat_cycle').config;
const text = ctx.flowSimpleHeatHelp(setting, 'room', false);
const night = setting.night_target;
assert.ok(text.includes(`${night.toFixed(1)}°C`), `${language} ${file} missing room target`);
assert.ok(text.includes(`${(night - 0.8).toFixed(1)}°C`), `${language} ${file} missing boost threshold`);
assert.ok(text.includes(`${Math.floor(night - 1).toFixed(1)}°C`), `${language} ${file} missing standby`);
assert.ok(text.includes(`${Math.floor(night) + 1}.0°C`), `${language} ${file} missing gentle heat`);
assert.ok(text.includes(`${Math.ceil(night + 2)}.0°C`), `${language} ${file} missing boost`);
assert.ok(text.includes(`${setting.day_min.toFixed(1)}°C`), `${language} ${file} missing daytime minimum`);
assert.ok(text.includes('21.0°C') && text.includes('22.0°C'), `${language} ${file} missing daytime setpoints`);
assert.ok(text.includes('19.2°C'), `${language} ${file} missing daytime immediate boost threshold`);
assert.ok(text.includes(String(setting.day_off_after_minutes)), `${language} ${file} missing off timer`);
assert.ok(text.includes(String(setting.boost_after_minutes)), `${language} ${file} missing boost timer`);
assert.ok(text.includes(localize(language, 'flow.simpleHeatHelpSensors')));
}
assert.ok(ctx.flowSimpleHeatHelp({ night_target: 22.4, day_target: 21.3, day_min: 20.6, boost_after_minutes: 12, day_off_after_minutes: 45 }).includes('25.0°C'), 'help must recalculate for edited targets');
}
ctx.language = 'pl';
function inspect(kind, config) {
app.flowDraft = { nodes: [{ id: 'selected', kind, config }], edges: [] };
app.flowSelectedNodeId = 'selected';
ctx.renderFlowInspector();
return host.innerHTML;
}
const constantHtml = inspect('constant', { value: true });
assert.ok(constantHtml.includes(localize('pl', 'flow.constantDescription')));
assert.ok(constantHtml.includes(localize('pl', 'flow.constantHelp')));
assert.ok(constantHtml.includes('data-flow-config="value"'));
const heat = { zone_id: 'zone1', day_target: 20, night_target: 20.6, day_min: 19.7, boost_after_minutes: 10, day_off_after_minutes: 30 };
const simpleHtml = inspect('zone_heat_cycle', heat);
assert.ok(simpleHtml.includes(localize('pl', 'flow.simpleHeatDescription')));
assert.ok(simpleHtml.includes('<details class="flow-block-help"'));
assert.ok(simpleHtml.includes(localize('pl', 'flow.simpleHeatHowItWorks')));
assert.ok(simpleHtml.includes('19.0°C') && simpleHtml.includes('23.0°C'));
assert.ok(simpleHtml.includes('data-flow-config="night_target"'));
host.expandedNode = 'selected';
assert.ok(inspect('zone_heat_cycle', heat).includes('data-flow-help-node="selected" open>'), 'expanded help should stay open when a user edits settings');
const htmlFile = fs.readFileSync(path.join(root, 'web/index.html'), 'utf8');
assert.ok(htmlFile.includes('data-i18n-title="flow.constantDescription"'));
assert.ok(htmlFile.includes('data-i18n-title="flow.simpleHeatDescription"'));
console.log('OK: 2 blocks, 3 rooms, PL/EN tips, dynamic temperature examples, inspector and expanded-state behavior');
if (process.env.FLOW_HELP_PREVIEW_HTML) {
host.expandedNode = 'selected';
const content = inspect('zone_heat_cycle', heat);
fs.writeFileSync(process.env.FLOW_HELP_PREVIEW_HTML, `<!doctype html><html lang="pl"><head><meta charset="utf-8"><link rel="stylesheet" href="file://${path.join(root, 'web/css/styles.css')}"><style>body{margin:0;padding:28px;background:var(--bg)} .flow-inspector{width:370px;max-height:none;box-sizing:border-box;border:1px solid var(--line-strong);border-radius:14px} .flow-inspector-head button{font-size:11px;padding:7px}</style></head><body><aside class="flow-inspector">${content}</aside></body></html>`);
}
+29
View File
@@ -0,0 +1,29 @@
// Focused UI behavior checks, with no browser or controller required.
const fs = require('fs');
const vm = require('vm');
const ctx = vm.createContext({
tr: key => key,
app: { zones: [], devices: [], groups: [] },
});
vm.runInContext(fs.readFileSync('web/js-dynamic/entities.js', 'utf8'), ctx);
const zone = {
enabled: true,
control_owner: 'automation',
control_source: 'automation.flow',
simple_heat: { day_target: 20, night_target: 20.6 },
current_temperature: 20.2,
demand: false,
};
function check(fn, expected, ...args) {
const value = ctx[fn](...args);
if (value !== expected) throw new Error(`${fn}: ${value} != ${expected}`);
}
check('zoneControlOwnerLabel', 'zones.ownerFlow', zone);
check('zoneRuntimeStatusLabel', 'zones.flowDayOff', zone, 'off', { power: false });
check('zoneRuntimeStatusLabel', 'zones.flowDayPendingOff', zone, 'off', { power: true });
check('zoneRuntimeStatusLabel', 'zones.flowStandby', zone, 'heat', { power: true, enabled: true, online: true });
check('zoneRuntimeStatusLabel', 'zones.flowHeating', { ...zone, demand: true }, 'heat', { power: true, enabled: true, online: true, mode: 'heat' });
check('zoneRuntimeStatusLabel', 'zones.flowCoasting', { ...zone, heat_coasting: true }, 'heat', { power: true, enabled: true, online: true });
check('zoneRuntimeStatusLabel', 'zones.manualDeviceControl', { ...zone, device_manual_override: true }, 'heat', { power: true });
check('zoneControlOwnerLabel', 'zones.ownerExternal', { ...zone, control_owner: 'direct_manual', control_source: 'external' });
console.log('OK: 8 UI Flow/manual status scenarios');
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Static release checks for the two-node Simple day/night heating Flows.
Does not replace `cargo test` or hardware verification.
"""
from __future__ import annotations
import json
import subprocess
import sys
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PROFILE_DIR = ROOT / "flows" / "simple-heating"
ROOMS = {
"sypialnia": ("9a0bb37d-0306-4f87-9b14-fabf3b4c6164", 20.5, 10),
"jan-pieczara": ("7feae66a-747e-4205-8cff-91cc0b7924f6", 20.8, 8),
"igor": ("dd451f3e-4fc0-4189-b1cf-0c814b65cefc", 20.8, 10),
}
TRANSLATIONS = [
"flow.node.simpleHeat", "flow.simpleHeatDay", "flow.simpleHeatNight",
"flow.simpleHeatMinimum", "flow.simpleHeatBoostMinutes",
"flow.simpleHeatOffMinutes", "flow.simpleHeatHint", "zones.flowDayOff", "zones.flowDayPendingOff",
"flow.constantDescription", "flow.constantHelp", "flow.simpleHeatDescription", "flow.simpleHeatHowItWorks",
"flow.simpleHeatHelpIntro", "flow.simpleHeatHelpNightTitle", "flow.simpleHeatHelpNightSleep",
"flow.simpleHeatHelpNightBrake", "flow.simpleHeatHelpNightGentle", "flow.simpleHeatHelpNightBoost",
"flow.simpleHeatHelpDayTitle", "flow.simpleHeatHelpDayOff", "flow.simpleHeatHelpDayHeat",
"flow.simpleHeatHelpDayStop", "flow.simpleHeatHelpSensors", "flow.simpleHeatHelpSchedule",
"flow.simpleHeatHelpControl", "flow.simpleHeatDayTip", "flow.simpleHeatNightTip",
"flow.simpleHeatMinimumTip", "flow.simpleHeatBoostTip", "flow.simpleHeatOffTip",
]
def main() -> None:
version = tomllib.loads((ROOT / "Cargo.toml").read_text())["package"]["version"]
for name, (zone_id, target, boost) in ROOMS.items():
path = PROFILE_DIR / f"{name}-simple-heat-v5.flow.json"
doc = json.loads(path.read_text())
assert doc["format"] == "gree-controller-flow" and doc["version"] == 1, name
graph = doc["flow"]
assert graph["enabled"] and not graph["draft"], name
assert len(graph["nodes"]) == 2 and len(graph["edges"]) == 1, name
conditions = [node for node in graph["nodes"] if node["kind"] == "constant"]
actions = [node for node in graph["nodes"] if node["kind"] == "zone_heat_cycle"]
assert len(conditions) == len(actions) == 1, name
assert conditions[0]["config"]["value"] is True, name
assert graph["edges"][0]["from"] == conditions[0]["id"], name
assert graph["edges"][0]["to"] == actions[0]["id"], name
conf = actions[0]["config"]
assert conf["zone_id"] == zone_id, name
assert conf["night_target"] == target, name
assert conf["day_target"] == 20 and conf["day_min"] == 19.7, name
assert conf["boost_after_minutes"] == boost, name
assert conf["day_off_after_minutes"] == 30, name
assert "Wymaga wersji aplikacji 0.16.2" in graph["description"], name
for lang in ("pl", "en"):
translations = json.loads((ROOT / "lang" / f"{lang}.json").read_text())["translations"]
assert all(translations.get(key) for key in TRANSLATIONS), lang
# Validate every distributable JSON input, not only the three new Flow exports.
json_files = list(ROOT.rglob("*.json"))
for path in json_files:
json.loads(path.read_text())
assert len(json_files) > 30, len(json_files)
js_files = list((ROOT / "web").rglob("*.js"))
for path in js_files:
subprocess.run(["node", "--check", str(path)], check=True, capture_output=True, text=True)
rust = (ROOT / "src" / "api" / "flows.rs").read_text()
engine = (ROOT / "src" / "engine" / "zone_control.rs").read_text()
auto = (ROOT / "src" / "engine" / "automations.rs").read_text()
status = (ROOT / "web" / "js-dynamic" / "entities.js").read_text()
ui = (ROOT / "web" / "js-dynamic" / "flows.js").read_text()
html = (ROOT / "web" / "index.html").read_text()
for value in ("zone_heat_cycle", "boost_after_minutes", "day_off_after_minutes"):
assert value in rust and value in ui, value
assert "simple_heat_step(" in engine and "action_zone_simple_heat" in auto
assert "zone.simple_heat" in engine and "enabled_heat_actions" in engine
assert "automation.flow" in status and "zones.flowDayOff" in status
assert 'data-flow-add="zone_heat_cycle"' in html
assert "day_target_20_starts_at_21_then_boosts_to_22" in (ROOT / "src" / "engine" / "simple_heat.rs").read_text()
lock = (ROOT / "Cargo.lock").read_text()
assert f'name = "gree-controller"\nversion = "{version}"' in lock
for manifest in [
ROOT / "ha-addon" / "repository" / "gree-controller" / "config.yaml",
ROOT / "ha-addon" / "home-assistant" / "custom_components" / "gree_controller" / "manifest.json",
]:
assert version in manifest.read_text(), manifest
assert json.loads((ROOT / "docs" / "openapi.json").read_text())["info"]["version"] == version
print(f"OK: {len(ROOMS)} Flow imports, {len(json_files)} JSON files, {len(js_files)} JS scripts, translations, linked controller source, version {version}")
if __name__ == "__main__":
try:
main()
except (AssertionError, subprocess.CalledProcessError) as exc:
print(f"FAIL: {exc}", file=sys.stderr)
raise