Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
- **`foxess` (H1/H3 11000-range map) — removed untested.** Every tested_devices entry said "Community driver, untested", and no hardware has ever validated the map. This is not a clean supersession: `foxess_h3_smart` covers the 1K5-HI and H3-Smart families, not the H1 / H3 / H3-PRO / AIO-H3 families the old driver claimed — those lose their only, unvalidated, listing. The removal is still right on this vendor's hardware behaviour: a Fox ESS inverter answers unknown registers with silence, so a wrong-map driver produces no error, only timeouts — a real 1K5 answered none of the 11000-range map, and the catalog offering it anyway cost that operator a full "device not supported" detour. A catalog that reports what is known must not list a map nobody has seen answer. H1-range coverage can return the way H3-Smart did: a driver written against hardware someone actually has.

### Changed
- **`myuplink` 1.2.0** — wraps cloud HTTP and JSON calls in `pcall`, so a failed request or malformed response returns the driver's retry path. The HTTP catalog rules now use `connectivity`: local drivers keep the config host, port and `http://` scheme, while cloud drivers use their vendor endpoint. The driver contract also accepts `host.emit_metric` for metrics-only drivers.
- **`foxess_h3_smart` 0.9.3** — records the verifier and the last hardware-test date beside its existing production claim, so FTW can include it in the offline recovery bundle without rejecting incomplete verification metadata. The evidence is unchanged: four days on one 1K5-HI-10-V1; H3-Smart hardware remains untested
- **The channel now repeats a reviewed verification claim instead of stamping every artifact "untested".** `tools/ftw_repository.py` hardcoded `verification_status: experimental` into every published artifact — a sound default when 80 unverified community ports were imported wholesale, and wrong the moment a driver earned a hardware-validated `production` status the review accepted. The builder now carries the DRIVER block's status (and its notes) into the artifact when the declared status is `production` or `beta` — a checkable claim a reviewer gated — and keeps the conservative default for everything else, which also keeps every already-published untested driver's bytes unchanged. A published version names a set of bytes, so the nine drivers whose artifacts change get patch bumps: `ctek` 0.3.2, `ctek_v2` 0.3.2, `easee_cloud` 1.1.1, `ferroamp` 2.1.1, `foxess_h3_smart` 0.9.2, `nibe_local` 1.1.2, `sungrow` 1.5.8, `tesla_vehicle` 0.2.1, `zap` 2.0.2
- **`foxess_h3_smart` 0.9.1** — `verification_status` moves `experimental` → `production` ("verified on hardware" in the FTW UI), with `verification_notes` recording exactly what was verified and on what: telemetry, battery dispatch in all three regimes, and PV curtailment on a 1K5-HI-10-V1 over multi-day operation — and that the H3-Smart family shares the map but remains untested on H3-Smart hardware. Header and description drop the "local control build" framing that stopped being true when the channel shipped it
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ Catalog source is not proof that a target can install or run a driver.
| kstar | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| mennekes | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| mennekes | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| myuplink | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| myuplink | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| myuplink | 1.2.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| myuplink | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| nibe_local | 1.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| nibe_local | 1.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| opendtu | 1.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -930,7 +930,7 @@ manufacturers:
protocols:
- protocol: http
driver: "myuplink"
version: "1.1.1"
version: "1.2.0"
ders: [heatpump]
control: false
firmware_versions: ""
Expand Down
33 changes: 26 additions & 7 deletions drivers/lua/myuplink.lua
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,18 @@ DRIVER = {
id = "myuplink",
name = "MyUplink Heat Pump (telemetry)",
manufacturer = "MyUplink (NIBE, Bosch, Atlantic, Daikin, ...)",
version = "1.0.0",
version = "1.2.0",
protocols = { "http" },
capabilities = { "apicreds" },
-- Says what the header, the description and driver_command have always
-- said. Without it the channel infers control from the mere presence of a
-- driver_command entrypoint and publishes this driver write-capable.
read_only = true,
-- ...but it can read nothing until it has signed in, and it signs in with a
-- POST. The generated read-only guard allows POST only to a URL ending here
-- and refuses it everywhere else, so this narrows the exemption rather than
-- asserting it. A path, not a URL, because base_url is config-overridable.
auth_post_path = "/oauth/token",
description = "Read-only heat-pump telemetry via MyUplink Cloud REST API v2: compressor power + hot-water/indoor/outdoor temperatures. Observe-only — no control. OAuth: authorization-code + refresh-token (connect in Settings → Devices).",
homepage = "https://dev.myuplink.com",
http_hosts = { "api.myuplink.com" },
Expand Down Expand Up @@ -107,16 +116,24 @@ local function fetch_token()
.. "&client_id=" .. url_encode(client_id)
.. "&client_secret=" .. url_encode(client_secret)
.. "&refresh_token=" .. url_encode(refresh_token)
local resp, err = host.http_post(
local post_ok, resp, err = pcall(host.http_post,
BASE_URL .. "/oauth/token", body,
{ ["Content-Type"] = "application/x-www-form-urlencoded" })
if not post_ok then
host.log("error", "MyUplink: token refresh failed: " .. tostring(resp))
return false
end
if err then
host.log("error", "MyUplink: token refresh failed: " .. tostring(err))
return false
end
local data = host.json_decode(resp)
local decode_ok, data, decode_err = pcall(host.json_decode, resp)
if not decode_ok then
host.log("error", "MyUplink: invalid refresh response: " .. tostring(data))
return false
end
if not data or not data.access_token then
host.log("error", "MyUplink: no access_token in refresh response")
host.log("error", "MyUplink: no access_token in refresh response: " .. tostring(decode_err))
return false
end
access_token = data.access_token
Expand Down Expand Up @@ -147,10 +164,12 @@ end
-- ---- API helpers ---------------------------------------------------------

local function api_get(path)
local resp, err = host.http_get(BASE_URL .. path, auth_headers())
local get_ok, resp, err = pcall(host.http_get, BASE_URL .. path, auth_headers())
if not get_ok then return nil, tostring(resp) end
if err then return nil, tostring(err) end
local data, derr = host.json_decode(resp)
if not data then return nil, tostring(derr) end
local decode_ok, data, derr = pcall(host.json_decode, resp)
if not decode_ok then return nil, tostring(data) end
if not data then return nil, tostring(derr or "empty JSON response") end
return data, nil
end

Expand Down
9 changes: 9 additions & 0 deletions drivers/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ def read_manifest(name):
return f.read()


def get_driver_connectivity(name):
"""Return the manifest's local/cloud connectivity classification."""
import re

match = re.search(r'^connectivity:\s*(\S+)', read_manifest(name), re.MULTILINE)
assert match, f"{name}: manifest has no connectivity field"
return match.group(1).strip('"')


def get_driver_names():
"""Get all driver names for parametrization."""
files = glob.glob(os.path.join(DRIVERS_DIR, "*.lua"))
Expand Down
21 changes: 17 additions & 4 deletions drivers/tests/test_http_drivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,29 @@
from conftest import (
read_driver,
get_http_drivers,
get_driver_connectivity,
strip_lua_comments,
)

HTTP_DRIVERS = get_http_drivers()


def skip_if_cloud(driver_name):
"""Local URL rules do not describe a driver that calls a vendor cloud."""
if get_driver_connectivity(driver_name) == "cloud":
pytest.skip(
f"{driver_name}: cloud HTTP driver uses its vendor endpoint, "
"not a local config host"
)


@pytest.mark.parametrize("driver_name", HTTP_DRIVERS)
class TestHttpPatterns:
"""Validate HTTP driver API usage patterns."""

def test_constructs_base_url(self, driver_name):
"""HTTP drivers should construct a base URL from config.host."""
"""Local HTTP drivers should construct a base URL from config.host."""
skip_if_cloud(driver_name)
code = read_driver(driver_name)
clean = strip_lua_comments(code)

Expand Down Expand Up @@ -82,10 +93,11 @@ def test_has_http_get_json_helper_or_inline(self, driver_name):

@pytest.mark.parametrize("driver_name", HTTP_DRIVERS)
class TestHttpUrlSafety:
"""Validate URL construction safety."""
"""Validate URL construction safety for local HTTP drivers."""

def test_uses_http_scheme(self, driver_name):
"""HTTP drivers should use http:// scheme (not https on constrained devices)."""
"""Local HTTP drivers should use http://, not HTTPS."""
skip_if_cloud(driver_name)
code = read_driver(driver_name)
clean = strip_lua_comments(code)

Expand All @@ -96,7 +108,8 @@ def test_uses_http_scheme(self, driver_name):
)

def test_uses_config_port(self, driver_name):
"""HTTP drivers should use config.port for the connection port."""
"""Local HTTP drivers should use config.port for the connection port."""
skip_if_cloud(driver_name)
code = read_driver(driver_name)
clean = strip_lua_comments(code)

Expand Down
6 changes: 3 additions & 3 deletions index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -369,15 +369,15 @@ drivers:
size_bytes: 3766
sha256: "5f985b8917aea7b08fba13c02506b45e40232a763393dc6af7c6eeb6ab5853af"
- name: "myuplink"
version: "1.1.1"
version: "1.2.0"
tier: core
protocol: http
connectivity: cloud
setup: [vendor_portal]
ders: [heatpump]
control: false
size_bytes: 15278
sha256: "d754fd4af96c2047f9d9e458a776ad8f10510569836618f4631dd70e2510f404"
size_bytes: 16386
sha256: "6074b70eb2bbe49481fcc59551d65474cb8129ae5370e9c1844c23b08a5f6fd6"
- name: "nibe_local"
version: "1.1.2"
tier: core
Expand Down
6 changes: 3 additions & 3 deletions manifests/myuplink.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "myuplink"
version: "1.1.1"
version: "1.2.0"
tier: core
author: "Sourceful Labs AB"
protocol: http
Expand All @@ -21,9 +21,9 @@ upstream_docs:
kind: changelog
url_stability: stable
min_host_version: "2.0.0"
size_bytes: 15278
size_bytes: 16386
dkb_id: "myuplink"
sha256: "d754fd4af96c2047f9d9e458a776ad8f10510569836618f4631dd70e2510f404"
sha256: "6074b70eb2bbe49481fcc59551d65474cb8129ae5370e9c1844c23b08a5f6fd6"
signature: ""

bytecode_sha256: ""
2 changes: 1 addition & 1 deletion support-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -1122,7 +1122,7 @@
},
{
"catalog_source": true,
"catalog_version": "1.1.1",
"catalog_version": "1.2.0",
"driver_id": "myuplink",
"package_id": null,
"targets": {
Expand Down
85 changes: 85 additions & 0 deletions tests/test_ftw_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from ftw_repository import ( # noqa: E402
RepositoryError,
_load_channel,
build_publication,
canonical_json,
check_publication,
Expand Down Expand Up @@ -266,6 +267,16 @@ def test_manifest_requires_exact_canonical_envelope_bytes(
),
"read-only driver has a write-capable permission",
),
# http.post is the one write permission a read-only driver may hold,
# and only when its metadata declares the sign-in it was granted for.
# Undeclared, it is refused like any other write path.
(
lambda driver: driver.update(
read_only=True, control_enabled=False,
permissions=["http.get", "http.post"],
),
"read-only driver has a write-capable permission",
),
(
lambda driver: driver["metadata"].update(read_only=False),
"Lua metadata read_only must match the manifest",
Expand Down Expand Up @@ -683,3 +694,77 @@ def test_a_driver_that_declares_read_only_keeps_its_write_guards(
assert sdm630["permissions"] == ["modbus.read"]
assert sdm630["read_only"] is True
assert sdm630["control_enabled"] is False


def test_a_read_only_driver_may_still_sign_in(
tmp_path: Path, keypair: tuple[str, str]
) -> None:
"""Reading after authenticating is still reading.

myuplink cannot actuate anything -- driver_command refuses every command it
is handed -- but it reads nothing until it has exchanged a refresh token,
and it exchanges it with a POST. Denying that POST would have cost a
read-only driver every reading it takes, so read-only would have been
unusable for the drivers that most obviously deserve it.
"""
manifest, output = build(tmp_path, keypair)
myuplink = next(d for d in manifest["drivers"] if d["id"] == "myuplink")
artifact = (output / Path(myuplink["url"]).name).read_text()

assert myuplink["read_only"] is True
assert myuplink["control_enabled"] is False
assert myuplink["metadata"]["auth_post_path"] == "/oauth/token"
assert "http.post" in myuplink["permissions"]

# The exemption is scoped, not a hole: POST reaches the real host function
# only for a URL ending in the declared path.
assert 'local __sourceful_ftw_auth_path = "/oauth/token"' in artifact
assert "path:sub(-#__sourceful_ftw_auth_path) == __sourceful_ftw_auth_path" in artifact
assert "POST is allowed only for authentication" in artifact
# Everything else a read-only driver must not do is still refused.
for denied in ("modbus_write", "modbus_write_multi", "mqtt_publish", "serial_write"):
assert f"host.{denied} = __sourceful_ftw_write_denied" in artifact


def test_signing_in_is_declared_or_it_does_not_happen(
tmp_path: Path, keypair: tuple[str, str]
) -> None:
"""A read-only driver that declares nothing keeps the blanket denial."""
manifest, output = build(tmp_path, keypair)
exempt = []
for driver in manifest["drivers"]:
if not driver["read_only"]:
continue
artifact = (output / Path(driver["url"]).name).read_text()
if "host.http_post = __sourceful_ftw_write_denied" not in artifact:
exempt.append(driver["id"])
else:
assert "auth_post_path" not in driver["metadata"], driver["id"]
assert "http.post" not in driver["permissions"], driver["id"]
assert exempt == ["myuplink"], f"unexpected drivers allowed to POST: {exempt}"


def test_auth_post_path_must_be_a_path_and_must_mean_something(
tmp_path: Path
) -> None:
"""Two ways to declare it wrongly, both refused while building."""
repo, config_path = single_driver_repo(tmp_path, "myuplink")
source = repo / "drivers" / "lua" / "myuplink.lua"
original = source.read_text(encoding="utf-8")

# A whole URL rather than a path: the guard matches on the path, so a URL
# here would silently never match and the driver could not sign in.
source.write_text(
original.replace('auth_post_path = "/oauth/token"',
'auth_post_path = "https://api.myuplink.com/oauth/token"'),
encoding="utf-8")
with pytest.raises(RepositoryError, match="must be a path beginning with"):
_load_channel(config_path, repo)

# Declared on a driver that is not read-only, where it exempts nothing.
source.write_text(
original.replace(" read_only = true,\n", ""), encoding="utf-8")
with pytest.raises(RepositoryError, match="only means anything with read_only"):
_load_channel(config_path, repo)

source.write_text(original, encoding="utf-8")
Loading