Add diagnostics platform, config-flow gap warning, issue template

Completes the device-capability-diagnostics spec:

- diagnostics.py: the standard HA diagnostics hook, returning device type,
  one_ui_version, unbound hrefs, and the redacted raw resource tree, plus
  integration and smartthings-local version numbers. This is what the
  Repairs issue (added in the previous commit) points users at, and what
  they attach to a device-support issue.
- config_flow.py: _probe_and_validate now also reports oneUiVersion and
  whether the device type is recognized. Recognized types are unaffected;
  an unrecognized type shows a new confirm_unknown_type step explaining
  that only common capabilities will be available before creating the
  entry, so expectations are set at setup time rather than only after the
  fact via Repairs.
- .github/ISSUE_TEMPLATE/device-support.yml: structured template for
  filing a capability gap, linked from both the Repairs issue and the
  config-flow confirmation step.
- README: short section pointing at this whole mechanism.
This commit is contained in:
Marc Billow
2026-07-08 00:10:30 -05:00
parent ca754cbb01
commit d2699f0f5b
9 changed files with 221 additions and 12 deletions
+31
View File
@@ -0,0 +1,31 @@
name: Device support / capability gap
description: Report an appliance type that wasn't recognized, or one that's missing some capabilities
labels: ["device-support"]
body:
- type: markdown
attributes:
value: |
Thanks for helping expand device coverage. The most useful thing you can attach here is a diagnostics
download: Settings > Devices & Services > this device > the menu (top right) > Download diagnostics.
It's already redacted of account/network identifiers before it's generated.
- type: input
id: model
attributes:
label: Appliance model
description: The model number or product name, if known (e.g. from a label on the appliance or its manual)
validations:
required: false
- type: textarea
id: description
attributes:
label: What's missing or wrong
description: What entities are missing, or what behavior didn't match what you expected
validations:
required: true
- type: textarea
id: diagnostics
attributes:
label: Diagnostics file
description: Drag and drop the downloaded diagnostics JSON file here
validations:
required: false
+11
View File
@@ -128,6 +128,17 @@ docker-compose.yml / ha_config/ Local HA dev environment
---
## Reporting a capability gap
If your appliance's type isn't recognized, or it exposes resources this integration doesn't model yet, a Repairs
issue appears under Settings > System > Repairs pointing you at Settings > Devices & Services > this device >
the menu > Download diagnostics. That download is already redacted of account/network identifiers (Bixby login
email, access tokens, device IDs, MAC addresses, serial numbers) before it's generated, so it's safe to attach
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
support for hardware the maintainers don't have.
---
## Adding a new appliance type
1. Capture the appliance's `/device/0` response to see what resources/fields it exposes. An authenticated `DtlsCoapSession` from `smartthings_local.protocol.dtls_session` GET is enough; `local-tools/probe_device.py` wraps this.
+44 -11
View File
@@ -125,6 +125,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
try:
@@ -170,11 +171,21 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
)
if not serial:
serial = f"{host}:{port}"
one_ui_version = (
resources
.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', '')
)
return {
"port": port,
"serial": serial,
"leaf_cert_pem": fullchain_pem,
"leaf_key_pem": leaf_key_pem,
"one_ui_version": one_ui_version,
"device_type_recognized": bool(
one_ui_version and for_device(one_ui_version) is not None
),
}
except CannotConnect:
raise
@@ -197,6 +208,20 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._host: str = ""
self._ca_cert_pem: str = ""
self._ca_key_pem: str = ""
self._pending_info: dict | None = None
def _create_entry(self, info: dict) -> FlowResult:
return self.async_create_entry(
title=f"Samsung Appliance ({self._host})",
data={
CONF_HOST: self._host,
CONF_PORT: info["port"],
CONF_CA_CERT_PEM: self._ca_cert_pem,
CONF_CA_KEY_PEM: self._ca_key_pem,
CONF_LEAF_CERT_PEM: info["leaf_cert_pem"],
CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -232,17 +257,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
else:
await self.async_set_unique_id(f"localthings_{info['serial']}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Samsung Appliance ({self._host})",
data={
CONF_HOST: self._host,
CONF_PORT: info["port"],
CONF_CA_CERT_PEM: self._ca_cert_pem,
CONF_CA_KEY_PEM: self._ca_key_pem,
CONF_LEAF_CERT_PEM: info["leaf_cert_pem"],
CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
},
)
if info["device_type_recognized"]:
return self._create_entry(info)
self._pending_info = info
return await self.async_step_confirm_unknown_type()
if has_creds:
schema = vol.Schema({vol.Required(CONF_HOST): _TEXT})
@@ -261,3 +279,18 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors,
description_placeholders={"reuse_note": reuse_note},
)
async def async_step_confirm_unknown_type(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Shown only when the probe already knows the device type is unrecognized."""
if user_input is not None:
return self._create_entry(self._pending_info)
return self.async_show_form(
step_id="confirm_unknown_type",
data_schema=vol.Schema({}),
description_placeholders={
"one_ui_version": self._pending_info["one_ui_version"] or "(none reported)",
},
)
@@ -0,0 +1,36 @@
"""Diagnostics support for Local Things.
Downloadable from Settings > Devices & Services > this integration's
device > the menu > Download diagnostics. This is what the Repairs issue
(raised in coordinator.py when capability coverage is incomplete) points
users at: a redacted snapshot of the device's raw /device/0 state, plus
enough version/coverage metadata to reproduce and diagnose the gap.
"""
from __future__ import annotations
from importlib.metadata import version as pkg_version
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.loader import async_get_integration
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.redact import redact_resources
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
integration = await async_get_integration(hass, DOMAIN)
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
"integration_version": integration.version,
"smartthings_local_version": pkg_version("smartthings-local"),
}
@@ -63,6 +63,10 @@
"ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.",
"ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer."
}
},
"confirm_unknown_type": {
"title": "Appliance type not recognized",
"description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
}
},
"error": {
@@ -63,6 +63,10 @@
"ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.",
"ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer."
}
},
"confirm_unknown_type": {
"title": "Appliance type not recognized",
"description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
}
},
"error": {
@@ -73,5 +77,11 @@
"abort": {
"already_configured": "This device is already configured."
}
},
"issues": {
"device_gap": {
"title": "Incomplete capability coverage for {device_name}",
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
}
}
}
+20 -1
View File
@@ -69,7 +69,7 @@ def fridge_resources():
@pytest.fixture
def mock_probe():
"""Patch _probe_and_validate to succeed without a real DTLS connection."""
"""Patch _probe_and_validate to succeed (recognized type) without a real DTLS connection."""
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
return_value={
@@ -77,6 +77,25 @@ def mock_probe():
'serial': MOCK_SERIAL,
'leaf_cert_pem': MOCK_LEAF_CERT_PEM,
'leaf_key_pem': MOCK_LEAF_KEY_PEM,
'one_ui_version': '7.0 Refrigerator',
'device_type_recognized': True,
},
) as m:
yield m
@pytest.fixture
def mock_probe_unknown_type():
"""Patch _probe_and_validate to succeed, but with an unrecognized device type."""
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
return_value={
'port': MOCK_PORT,
'serial': MOCK_SERIAL,
'leaf_cert_pem': MOCK_LEAF_CERT_PEM,
'leaf_key_pem': MOCK_LEAF_KEY_PEM,
'one_ui_version': '9.0 Space Heater',
'device_type_recognized': False,
},
) as m:
yield m
+36
View File
@@ -75,6 +75,42 @@ async def test_cannot_connect(hass: HomeAssistant) -> None:
assert result['errors']['base'] == 'cannot_connect'
async def test_recognized_type_skips_confirmation_step(
hass: HomeAssistant, mock_probe
) -> None:
"""A recognized device type creates the entry with no extra step."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
)
assert result['type'] == FlowResultType.CREATE_ENTRY
async def test_unknown_type_shows_confirmation_step(
hass: HomeAssistant, mock_probe_unknown_type
) -> None:
"""An unrecognized device type shows a confirmation step before creating the entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'confirm_unknown_type'
assert result['description_placeholders']['one_ui_version'] == '9.0 Space Heater'
result = await hass.config_entries.flow.async_configure(
result['flow_id'], {},
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert result['data'][CONF_HOST] == MOCK_HOST
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
"""Second add of same serial: flow aborts.
+29
View File
@@ -0,0 +1,29 @@
"""Tests for the diagnostics platform."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
from custom_components.localthings.registry.redact import REDACTED
async def test_diagnostics_shape_and_redaction(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
diagnostics = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diagnostics["device_type"] == 'refrigerator'
assert diagnostics["one_ui_version"] == '7.0 Refrigerator'
assert '/bespoke/vs/0' in diagnostics["unbound_hrefs"]
assert diagnostics["integration_version"] == '0.1.0'
assert diagnostics["smartthings_local_version"]
resources = diagnostics["resources"]
assert resources['/information/vs/0']['x.com.samsung.da.serialNum'] == REDACTED
assert resources['/wirelessinfo/vs/0']['macaddressWiFi'] == REDACTED
# Ordinary state survives.
assert resources['/status/lock/vs/0']['x.com.samsung.da.ado.devicecontrol'] == 'On'