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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user