v0.16.3
This commit is contained in:
1 parent
f376f0a7b6
commit
8059e16d12
36 files changed
+1140
-193
No files matched your search
@@ -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.
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/"/g, '"'),
|
||||
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>`);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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
|
||||
Reference in new issue
Block a user