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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html

## [Unreleased]

### 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.

### Added
- **A driver-authoring rule for the hybrid inverter that has no battery** — *A hybrid inverter may have no battery* in `docs/WRITING-A-DRIVER.md`, and rule 8 in `drivers/lua/GUIDELINES.md`. The existing rules cover a read that **failed**; this is the case where nothing failed — the device is healthy, every register answered, and the battery still is not there. Nearly every hybrid inverter is sold both with storage and without it under one model number and one register map, so a PV-only site is not an edge case, it is half the product line. The SG12RT already cited at the top of that document is this same fact arriving as an outage rather than as a wrong number. The rule: fill each battery field only from a register that answered, emit the DER only when at least one did, and **detect** it rather than reading it off the model number or asking the operator to declare it — the site nobody told the driver about is exactly the one that reports wrong. Records how the absence actually arrives, which is vendor-specific and cannot be assumed from one example: registers that go silent (Sigenergy), that answer a plain zero, that answer `0xFFFF`/`0x7FFFFFFF`/NaN (`sma` and `solis` already carry sentinel helpers), or that fault
- The rule is stated with the catalog measured rather than asserted: **24 drivers emit both `pv` and `battery`, and 20 of them emit the battery DER with no guard on whether any battery register answered.** Two of the 20 (`ferroamp`, `zap`) gate it on configuration or on API discovery instead — better than nothing, and still not detection, which is why the rule names the difference. `sigenergy` 1.1.3 below is the worked example of the fix. The other 19 are not touched here: each is its own driver, its own register map and its own version, and a sweep that changes nineteen drivers at once is not reviewable
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,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.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| nibe_local | 1.1.1 | 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 @@ -985,7 +985,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
8 changes: 4 additions & 4 deletions drivers/tests/test_driver_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ def test_calls_set_make_in_init(self, driver_name):
assert 'host.set_make(' in code, \
f"{driver_name}: should call host.set_make() in driver_init"

def test_calls_emit_in_poll(self, driver_name):
"""driver_poll should call host.emit()."""
def test_emits_in_poll(self, driver_name):
"""driver_poll should emit DER data or a metric."""
if driver_name == "hello":
pytest.skip("hello driver is a demo-only driver")
code = read_driver(driver_name)
assert 'host.emit(' in code, \
f"{driver_name}: should call host.emit() in driver_poll"
assert 'host.emit(' in code or 'host.emit_metric(' in code, \
f"{driver_name}: should call host.emit() or host.emit_metric() in driver_poll"

def test_no_forbidden_globals(self, driver_name):
"""Driver must not use forbidden sandbox-escaping functions."""
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 @@ -378,15 +378,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.1"
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 @@ -1150,7 +1150,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
Loading