From 3cc5dbfc973af9eba832430df6cd2a70e6f441f0 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Thu, 27 Aug 2026 11:40:36 -0500 Subject: [PATCH 01/31] WWSTCERT-13182 Govee Strip Light 2 (#3196) * WWSTCERT-13182 Govee Strip Light 2 * Update fingerprints.yml * Update fingerprints.yml --- .../matter-switch/fingerprints.yml | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 29c85f6c79..1a56ecce38 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -809,7 +809,7 @@ matterManufacturer: productId: 0x6870 deviceProfileName: light-color-level - id: "4999/26272" - deviceLabel: Govee TV Backlight 3 Pro 55-65 + deviceLabel: Govee TV Backlight 3 PRO / 3S PRO vendorId: 0x1387 productId: 0x66A0 deviceProfileName: light-color-level @@ -898,6 +898,26 @@ matterManufacturer: vendorId: 0x1387 productId: 0x60B3 deviceProfileName: light-color-level + - id: "4999/6723" + deviceLabel: Govee Strip Light 2 + vendorId: 0x1387 + productId: 0x1A43 + deviceProfileName: light-color-level + - id: "4999/6001" + deviceLabel: Govee Wallwash Table Lamp + vendorId: 0x1387 + productId: 0x1771 + deviceProfileName: light-color-level + - id: "4999/6161" + deviceLabel: Govee Bubble Projector Light + vendorId: 0x1387 + productId: 0x1811 + deviceProfileName: light-color-level + - id: "4999/10816" + deviceLabel: Govee TV Backlight 3 + vendorId: 0x1387 + productId: 0x2A40 + deviceProfileName: light-color-level # Hager - id: "4741/8" deviceLabel: Hager matter 2 buttons (battery) From dd29cc88b5075b1f8f0984912087b2a24eff9f14 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Thu, 27 Aug 2026 11:48:28 -0500 Subject: [PATCH 02/31] WWSTCERT-11168 Smart Radiator Thermostat X (#2949) --- .../matter-thermostat/fingerprints.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/drivers/SmartThings/matter-thermostat/fingerprints.yml b/drivers/SmartThings/matter-thermostat/fingerprints.yml index 814261790e..399f3861fc 100644 --- a/drivers/SmartThings/matter-thermostat/fingerprints.yml +++ b/drivers/SmartThings/matter-thermostat/fingerprints.yml @@ -89,6 +89,21 @@ matterManufacturer: vendorId: 0x134E productId: 0x0002 deviceProfileName: thermostat-humidity-heating-only-nostate-nobattery + - id: "4942/7" + deviceLabel: Smart Thermostat X (2nd Gen) + vendorId: 0x134E + productId: 0x0007 + deviceProfileName: thermostat-humidity-heating-only-nostate-batteryLevel + - id: "4942/8" + deviceLabel: Wireless Temperature Sensor X (2nd Gen) + vendorId: 0x134E + productId: 0x0008 + deviceProfileName: thermostat-humidity-heating-only-nostate-batteryLevel + - id: "4942/9" + deviceLabel: Smart Radiator Thermostat X + vendorId: 0x134E + productId: 0x0009 + deviceProfileName: thermostat-humidity-heating-only-nostate-batteryLevel #Taruie - id: "5151/4101" deviceLabel: TARUIE AC Remote From 72b71ab860db9b1e2e7018321e998763d5c1e26f Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Thu, 27 Aug 2026 13:38:44 -0500 Subject: [PATCH 03/31] WWSTCERT-13051 Govee Monitor Light Bar (#3178) --- .../matter-switch/fingerprints.yml | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 1a56ecce38..3609cb7910 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -898,6 +898,35 @@ matterManufacturer: vendorId: 0x1387 productId: 0x60B3 deviceProfileName: light-color-level + - id: "4999/10240" + deviceLabel: Govee Monitor Light Bar + vendorId: 0x1387 + productId: 0x2800 + deviceProfileName: light-color-level + - id: "4999/6833" + deviceLabel: Govee COB Strip Light 2 9.8ft/3m + vendorId: 0x1387 + productId: 0x1AB1 + deviceProfileName: light-color-level + - id: "4999/6834" + deviceLabel: Govee COB Strip Light 2 16.4ft/5m + vendorId: 0x1387 + productId: 0x1AB2 + deviceProfileName: light-color-level + - id: "4999/6835" + deviceLabel: Govee COB Strip Light 2 32.8ft/10m + vendorId: 0x1387 + productId: 0x1AB3 + deviceProfileName: light-color-level + - id: "4999/6818" + deviceLabel: Govee COB Strip Light 2 Pro 6.5ft/2m + vendorId: 0x1387 + productId: 0x1AA2 + deviceProfileName: light-color-level + - id: "4999/6821" + deviceLabel: Govee COB Strip Light 2 Pro 16.4ft/5m + vendorId: 0x1387 + productId: 0x1AA5 - id: "4999/6723" deviceLabel: Govee Strip Light 2 vendorId: 0x1387 From e0f54fbf28f9620ebd257580fb3eeeca8a9073e9 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Thu, 27 Aug 2026 14:01:29 -0500 Subject: [PATCH 04/31] WWSTCERT-13508 Onvis Outlet T20 (#3199) --- drivers/SmartThings/matter-switch/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 3609cb7910..b840abbd57 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -1688,6 +1688,11 @@ matterManufacturer: vendorId: 0x143D productId: 0x1001 deviceProfileName: plug-binary + - id: "5181/4098" + deviceLabel: Onvis Outlet T20 + vendorId: 0x143D + productId: 0x1002 + deviceProfileName: plug-power-energy-powerConsumption #Osram - id: "4489/2564" deviceLabel: OSRAM MATTER PLUG UK From 6b8a9cf69462d314a7c81b7a709037744d7d788d Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Fri, 28 Aug 2026 11:30:22 -0500 Subject: [PATCH 05/31] Fix profile name in fingerprint for Govee COB Strip Light (#3200) --- drivers/SmartThings/matter-switch/fingerprints.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index b840abbd57..dbfe3156b7 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -927,6 +927,7 @@ matterManufacturer: deviceLabel: Govee COB Strip Light 2 Pro 16.4ft/5m vendorId: 0x1387 productId: 0x1AA5 + deviceProfileName: light-color-level - id: "4999/6723" deviceLabel: Govee Strip Light 2 vendorId: 0x1387 From ab673162b891f6a54c06dd5c35dc771db6976470 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Wed, 22 Jul 2026 14:28:22 -0500 Subject: [PATCH 06/31] GHA: Validate Fingerprints --- .github/scripts/validate_fingerprints.py | 361 ++++++++++++++++++ .github/workflows/validate-fingerprints.yml | 24 ++ drivers/SmartThings/hub/fingerprints.yml | 2 +- .../matter-switch/fingerprints.yml | 5 - .../zigbee-button/fingerprints.yml | 2 +- .../zigbee-smoke-detector/fingerprints.yml | 2 +- .../zigbee-switch/fingerprints.yml | 12 +- .../zigbee-water-leak-sensor/fingerprints.yml | 2 +- .../zwave-garage-door-opener/fingerprints.yml | 2 +- .../SmartThings/zwave-switch/fingerprints.yml | 2 +- .../fingerprints.yml | 1 - .../zwave-window-treatment/fingerprints.yml | 2 +- 12 files changed, 393 insertions(+), 24 deletions(-) create mode 100644 .github/scripts/validate_fingerprints.py create mode 100644 .github/workflows/validate-fingerprints.yml diff --git a/.github/scripts/validate_fingerprints.py b/.github/scripts/validate_fingerprints.py new file mode 100644 index 0000000000..4b7dbc12b2 --- /dev/null +++ b/.github/scripts/validate_fingerprints.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +""" +Validate fingerprints.yml files changed in a PR. + +Required fields per manufacturer section: + matterManufacturer → id, deviceLabel, vendorId, productId, deviceProfileName + zigbeeManufacturer → id, deviceLabel, manufacturer, model, deviceProfileName + zwaveManufacturer → id, deviceLabel, manufacturerId, deviceProfileName + + at least one of: productId, productType + +Hex fields (vendorId, productId, productType, manufacturerId) must use 0xNNNN notation. +String fields must not be empty or have leading/trailing whitespace. +id values that contain YAML-special characters must be quoted. + +Indentation rules (spaces only, no tabs): + Section key: col 0 e.g. "matterManufacturer:" + Entry opening (- id: ...): 2-space e.g. " - id: ..." + All other entry fields: 4-space e.g. " vendorId: 0x115F" + +No trailing whitespace on any line. +No duplicate id values within a file. + +Generic sections (zigbeeGeneric, zwaveGeneric, matterGeneric, etc.) are skipped. + +Usage: + python3 tools/validate_fingerprints.py # auto-detect via git diff + python3 tools/validate_fingerprints.py path/fingerprints.yml ... +""" + +import os +import re +import sys +import subprocess +from pathlib import Path + +try: + import yaml +except ImportError: + print("Error: pyyaml is required. pip install pyyaml", file=sys.stderr) + sys.exit(2) + +# ── Section configuration ───────────────────────────────────────────────────── + +MANUFACTURER_SECTIONS = {'matterManufacturer', 'zigbeeManufacturer', 'zwaveManufacturer'} + +# All fields that must be present in every entry for each section. +# Z-Wave also needs productId OR productType (checked separately). +REQUIRED_FIELDS = { + 'matterManufacturer': ['id', 'deviceLabel', 'vendorId', 'productId', 'deviceProfileName'], + # manufacturer and model are checked together below: at least one must be present + 'zigbeeManufacturer': ['id', 'deviceLabel', 'deviceProfileName'], + 'zwaveManufacturer': ['id', 'deviceLabel', 'manufacturerId', 'deviceProfileName'], +} + +# These fields must be formatted as hex literals (0xNNNN). +HEX_FIELDS = {'vendorId', 'productId', 'productType', 'manufacturerId'} + +# YAML characters that force quoting when present in an unquoted scalar. +_YAML_SPECIAL_RE = re.compile(r'[:{}\[\],&#*?|<>=!%@`]') + +# Matches a valid hex literal. +_HEX_RE = re.compile(r'^0x[0-9A-Fa-f]+$') + +# Matches a field line: (indent)(key): (value) +_FIELD_RE = re.compile(r'^( *)([\w]+): *(.*?) *$') + + +# ── Raw-text analysis ───────────────────────────────────────────────────────── + +def analyse_lines(lines, filepath): + """ + Single-pass raw-line analysis. Returns a list of errors and a dict: + section_entry_lines[section] = list of (lineno, field, raw_value) + for structured cross-checking later. + """ + errors = [] + section = None # current top-level key + entry_indent = None # indent of the " - id:" line for current entry + in_manufacturer_section = False + + for lineno, raw in enumerate(lines, 1): + line = raw.rstrip('\n') + + # ── No tabs ─────────────────────────────────────────────────────────── + if '\t' in line: + errors.append(f"{filepath}:{lineno}: tab character found (use spaces)") + + # ── Trailing whitespace ─────────────────────────────────────────────── + if line != line.rstrip(): + errors.append(f"{filepath}:{lineno}: trailing whitespace") + + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + + indent = len(line) - len(line.lstrip()) + + # ── Top-level section key detection ─────────────────────────────────── + if indent == 0 and line.endswith(':') and not line.startswith(' '): + section = line[:-1].strip() + in_manufacturer_section = section in MANUFACTURER_SECTIONS + entry_indent = None + continue + + if not in_manufacturer_section: + continue + + # ── Entry opening line: " - id: ..." ─────────────────────────────── + if stripped.startswith('- '): + if indent != 2: + errors.append( + f"{filepath}:{lineno}: [{section}] entry list item must be indented " + f"2 spaces, found {indent}" + ) + entry_indent = indent + + # Check that this is the id field + rest = stripped[2:] # strip "- " + m = _FIELD_RE.match(' ' + rest) # re-prefix spaces for consistent match + if m: + field = m.group(2) + raw_val = m.group(3) + if field == 'id': + _check_id_quoting(raw_val, lineno, section, filepath, errors) + continue + + # ── Subsequent fields of an entry ───────────────────────────────────── + if entry_indent is not None: + expected_indent = entry_indent + 2 # 2 + 2 = 4 + if indent != expected_indent: + errors.append( + f"{filepath}:{lineno}: [{section}] field must be indented " + f"{expected_indent} spaces, found {indent}" + ) + + m = _FIELD_RE.match(line) + if not m: + continue + field = m.group(2) + raw_val = m.group(3).split('#')[0].strip() # strip inline comment + + # ── Hex field format ────────────────────────────────────────────── + if field in HEX_FIELDS and raw_val: + if not _HEX_RE.match(raw_val): + errors.append( + f"{filepath}:{lineno}: [{section}] field '{field}' " + f"value {raw_val!r} must be hex notation (e.g. 0x115F)" + ) + + # ── String field whitespace ─────────────────────────────────────── + display_val = _strip_quotes(raw_val) + if field not in HEX_FIELDS and raw_val: + if display_val != display_val.strip(): + errors.append( + f"{filepath}:{lineno}: [{section}] field '{field}' " + f"value has leading/trailing whitespace: {raw_val!r}" + ) + + return errors + + +def _strip_yaml_inline_comment(raw_val): + """ + Strip an inline YAML comment from a raw scalar value. + + Handles: + "quoted value" # comment → "quoted value" + 'quoted value' # comment → 'quoted value' + bare value # comment → bare value + """ + if raw_val and raw_val[0] in ('"', "'"): + q = raw_val[0] + i = 1 + while i < len(raw_val): + ch = raw_val[i] + if q == '"' and ch == '\\': + i += 2 # skip escaped character + continue + if q == "'" and ch == "'" and i + 1 < len(raw_val) and raw_val[i + 1] == "'": + i += 2 # escaped single-quote inside single-quoted string + continue + if ch == q: + return raw_val[:i + 1] # return up to and including closing quote + i += 1 + return raw_val # unclosed quote — return as-is + # Unquoted: strip from ' #' (space + hash = inline comment marker) + idx = raw_val.find(' #') + if idx != -1: + return raw_val[:idx].rstrip() + return raw_val + + +def _check_id_quoting(raw_val, lineno, section, filepath, errors): + """Require quoting when the id value contains YAML-special characters.""" + raw_val = _strip_yaml_inline_comment(raw_val) + is_quoted = ( + len(raw_val) >= 2 + and raw_val[0] in ('"', "'") + and raw_val[-1] == raw_val[0] + ) + inner = raw_val[1:-1] if is_quoted else raw_val + if not is_quoted and _YAML_SPECIAL_RE.search(inner): + errors.append( + f"{filepath}:{lineno}: [{section}] id value {raw_val!r} contains " + f"special characters and must be quoted" + ) + + +def _strip_quotes(val): + if len(val) >= 2 and val[0] in ('"', "'") and val[-1] == val[0]: + return val[1:-1] + return val + + +# ── YAML structural checks ──────────────────────────────────────────────────── + +def check_structure(data, filepath): + errors = [] + + for section, entries in data.items(): + if section not in MANUFACTURER_SECTIONS: + continue + + if not isinstance(entries, list): + errors.append(f"{filepath}: [{section}] expected a list of entries, got {type(entries).__name__}") + continue + + seen_ids = {} + for entry in entries: + if not isinstance(entry, dict): + errors.append(f"{filepath}: [{section}] entry is not a mapping: {entry!r}") + continue + + entry_id = entry.get('id', '') + + # ── Duplicate id ────────────────────────────────────────────────── + if entry_id in seen_ids: + errors.append(f"{filepath}: [{section}] duplicate id {entry_id!r}") + seen_ids[entry_id] = True + + # ── Required fields ─────────────────────────────────────────────── + # manufacturer and model may legitimately be "" (device reports no value). + ALLOW_EMPTY = {'manufacturer', 'model'} + for field in REQUIRED_FIELDS[section]: + if field not in entry: + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + f"missing required field '{field}'" + ) + elif entry[field] is None: + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + f"field '{field}' has a null value" + ) + elif field not in ALLOW_EMPTY and isinstance(entry[field], str) and entry[field].strip() == '': + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + f"field '{field}' is empty" + ) + + # ── Zigbee: manufacturer or model ───────────────────────────────── + if section == 'zigbeeManufacturer': + mfr = entry.get('manufacturer') + mdl = entry.get('model') + mfr_blank = mfr is None or (isinstance(mfr, str) and mfr.strip() == '') + mdl_blank = mdl is None or (isinstance(mdl, str) and mdl.strip() == '') + if mfr_blank and mdl_blank: + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + "at least one of 'manufacturer' or 'model' must be provided" + ) + + # ── Z-Wave: productId or productType ────────────────────────────── + if section == 'zwaveManufacturer': + if 'productId' not in entry and 'productType' not in entry: + errors.append( + f"{filepath}: [{section}] id={entry_id!r}: " + "missing 'productId' or 'productType' (at least one required)" + ) + + return errors + + +# ── File validator ──────────────────────────────────────────────────────────── + +def validate_file(filepath: Path) -> list: + try: + raw = filepath.read_text(encoding='utf-8') + except OSError as exc: + return [f"{filepath}: cannot read file — {exc}"] + + lines = raw.splitlines(keepends=True) + + # Raw-text pass (indentation, whitespace, hex format, id quoting) + errors = analyse_lines(lines, str(filepath)) + + # YAML structural pass (required fields, duplicates, null/empty values) + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as exc: + return errors + [f"{filepath}: YAML parse error — {exc}"] + + if not isinstance(data, dict): + return errors + [f"{filepath}: unexpected top-level YAML structure"] + + errors.extend(check_structure(data, str(filepath))) + return errors + + +# ── Git helper ──────────────────────────────────────────────────────────────── + +def get_changed_files() -> list: + base = os.environ.get('GITHUB_BASE_REF', 'main') + for ref in (f'origin/{base}', base, 'HEAD~1'): + try: + result = subprocess.run( + ['git', 'diff', '--name-only', '--diff-filter=AM', f'{ref}...HEAD'], + capture_output=True, text=True, check=True + ) + return [ + Path(f) for f in result.stdout.splitlines() + if f.endswith('fingerprints.yml') and Path(f).exists() + ] + except subprocess.CalledProcessError: + continue + return [] + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + if len(sys.argv) > 1: + files = [Path(f) for f in sys.argv[1:]] + else: + files = get_changed_files() + if not files: + print("No changed fingerprints.yml files detected.") + sys.exit(0) + + all_errors = [] + for f in files: + if not f.exists(): + print(f"Warning: {f} does not exist, skipping", file=sys.stderr) + continue + print(f"Checking {f} ...") + errs = validate_file(f) + all_errors.extend(errs) + + if all_errors: + print() + for err in all_errors: + print(err) + print(f"\n✗ {len(all_errors)} error(s) found.") + sys.exit(1) + else: + print(f"\n✓ {len(files)} file(s) passed validation.") + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/.github/workflows/validate-fingerprints.yml b/.github/workflows/validate-fingerprints.yml new file mode 100644 index 0000000000..6fc32268bd --- /dev/null +++ b/.github/workflows/validate-fingerprints.yml @@ -0,0 +1,24 @@ +name: Validate fingerprints + +on: + pull_request: + types: [opened, synchronize] + paths: + - 'drivers/**/fingerprints.yml' + +jobs: + validate-fingerprints: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install pyyaml + run: pip install pyyaml --quiet + + - name: Validate changed fingerprints.yml files + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: python .github/scripts/validate_fingerprints.py diff --git a/drivers/SmartThings/hub/fingerprints.yml b/drivers/SmartThings/hub/fingerprints.yml index 317df13f8b..887f1a4455 100644 --- a/drivers/SmartThings/hub/fingerprints.yml +++ b/drivers/SmartThings/hub/fingerprints.yml @@ -17,7 +17,7 @@ hub: deviceProfileName: washer-dryer-hub - id: "cooktop-hub" deviceLabel: SmartThings Hub - hardwareType: SAMSUNG_COOKTOP_TIZEN_OPEN + hardwareType: SAMSUNG_COOKTOP_TIZEN_OPEN deviceProfileName: cooktop-hub - id: "v4-hub" deviceLabel: SmartThings Hub diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index dbfe3156b7..4590ba0a74 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -2380,11 +2380,6 @@ matterManufacturer: vendorId: 0x100b productId: 0x21B3 deviceProfileName: light-color-level-2200K-6500K - - id: "4107/8627" - deviceLabel: WiZ Downlight - vendorId: 0x100b - productId: 0x21B3 - deviceProfileName: light-color-level-2200K-6500K - id: "4107/8796" deviceLabel: WiZ Downlight vendorId: 0x100b diff --git a/drivers/SmartThings/zigbee-button/fingerprints.yml b/drivers/SmartThings/zigbee-button/fingerprints.yml index 347fa818df..119b8a18ee 100644 --- a/drivers/SmartThings/zigbee-button/fingerprints.yml +++ b/drivers/SmartThings/zigbee-button/fingerprints.yml @@ -248,7 +248,7 @@ zigbeeManufacturer: deviceLabel: ITM Switch manufacturer: Samsung Electronics model: SAMSUNG-ITM-Z-005 - deviceProfileName: SLED-three-buttons + deviceProfileName: SLED-three-buttons - id: "Linxura Smart Controller" deviceLabel: Linxura Smart Controller manufacturer: Linxura diff --git a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml index 6fce8d0d3a..837463ab08 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml +++ b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml @@ -5,7 +5,7 @@ zigbeeManufacturer: model: lumi.sensor_gas.acn02 deviceProfileName: gas-lifetime-selfcheck-aqara - id: "LUMI/lumi.sensor_smoke.acn03" - deviceLabel: Aqara Smart Smoke Detector + deviceLabel: Aqara Smart Smoke Detector manufacturer: LUMI model: lumi.sensor_smoke.acn03 deviceProfileName: smoke-battery-aqara diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index bebe5295a2..bb2ce5f027 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -1744,7 +1744,7 @@ zigbeeManufacturer: deviceLabel: SMART ZIGBEE PLUG EU EM T manufacturer: LEDVANCE model: PLUG EU EM T - deviceProfileName: switch-power-energy + deviceProfileName: switch-power-energy - id: "OSRAM/LIGHTIFY Edge-lit flushmount" deviceLabel: SYLVANIA Light manufacturer: OSRAM @@ -2115,11 +2115,6 @@ zigbeeManufacturer: manufacturer: OSRAM model: CLA60 RGBW OSRAM deviceProfileName: rgbw-bulb - - id: "OSRAM/Flex RGBW" - deviceLabel: OSRAM Light - manufacturer: OSRAM - model: Flex RGBW - deviceProfileName: rgbw-bulb - id: "OSRAM/Gardenpole RGBW-Lightify" deviceLabel: OSRAM Light manufacturer: OSRAM @@ -2230,11 +2225,6 @@ zigbeeManufacturer: manufacturer: IKEA of Sweden model: JORMLIEN door WS 40x80 deviceProfileName: color-temp-bulb-2200K-4000K - - id: "OSRAM/Classic B40 TW - LIGHTIFY" - deviceLabel: OSRAM Light - manufacturer: OSRAM - model: Classic B40 TW - LIGHTIFY - deviceProfileName: color-temp-bulb - id: "OSRAM/CLA60 TW OSRAM" deviceLabel: OSRAM Light manufacturer: OSRAM diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml index e7007de4f7..9745b5c652 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml @@ -108,7 +108,7 @@ zigbeeManufacturer: deviceLabel: Sengled Water Leak Sensor manufacturer: sengled model: E1L-G7K - deviceProfileName: water-battery + deviceProfileName: water-battery - id: NEO/NAS_WS11 deviceLabel: NEO Water Leak Sensor manufacturer: NEO diff --git a/drivers/SmartThings/zwave-garage-door-opener/fingerprints.yml b/drivers/SmartThings/zwave-garage-door-opener/fingerprints.yml index e15aee1a63..006334ea60 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/fingerprints.yml +++ b/drivers/SmartThings/zwave-garage-door-opener/fingerprints.yml @@ -41,4 +41,4 @@ zwaveGeneric: commandClasses: supported: - 0x98 - deviceProfileName: base-garage-door + deviceProfileName: base-garage-door diff --git a/drivers/SmartThings/zwave-switch/fingerprints.yml b/drivers/SmartThings/zwave-switch/fingerprints.yml index f606edf119..8084a7bbf0 100644 --- a/drivers/SmartThings/zwave-switch/fingerprints.yml +++ b/drivers/SmartThings/zwave-switch/fingerprints.yml @@ -965,7 +965,7 @@ zwaveManufacturer: manufacturerId: 0x0460 productId: 0x0083 productType: 0x0002 - deviceProfileName: switch-binary + deviceProfileName: switch-binary - id: 1120/2/132 deviceLabel: Wave 1PM manufacturerId: 0x0460 diff --git a/drivers/SmartThings/zwave-virtual-momentary-switch/fingerprints.yml b/drivers/SmartThings/zwave-virtual-momentary-switch/fingerprints.yml index b93e7f071a..b62288b90e 100644 --- a/drivers/SmartThings/zwave-virtual-momentary-switch/fingerprints.yml +++ b/drivers/SmartThings/zwave-virtual-momentary-switch/fingerprints.yml @@ -1,4 +1,3 @@ -zwaveManufacturer: zwaveGeneric: - id: "GenericSwitch/1" deviceLabel: Switch diff --git a/drivers/SmartThings/zwave-window-treatment/fingerprints.yml b/drivers/SmartThings/zwave-window-treatment/fingerprints.yml index 0eed587497..13c648f3e7 100644 --- a/drivers/SmartThings/zwave-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zwave-window-treatment/fingerprints.yml @@ -62,7 +62,7 @@ zwaveManufacturer: manufacturerId: 0x0115 productId: 0x0010 productType: 0x0211 - deviceProfileName: window-treatment-preset-reverse + deviceProfileName: window-treatment-preset-reverse zwaveGeneric: - id: window-treatment/generic/1 deviceLabel: Z-Wave Window Treatment From 8cf0b5025b6ee284f1be7ae98bc41f2156a482ab Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Mon, 31 Aug 2026 11:35:41 -0500 Subject: [PATCH 07/31] WWSTCERT-13411 Z-Wave Smart Plug (#3207) --- drivers/SmartThings/zwave-switch/fingerprints.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/drivers/SmartThings/zwave-switch/fingerprints.yml b/drivers/SmartThings/zwave-switch/fingerprints.yml index 8084a7bbf0..77aba90d2c 100644 --- a/drivers/SmartThings/zwave-switch/fingerprints.yml +++ b/drivers/SmartThings/zwave-switch/fingerprints.yml @@ -885,6 +885,12 @@ zwaveManufacturer: productId: 0xFF97 productType: 0xFF01 deviceProfileName: switch-binary + - id: "0312/FF00/FF0C" + deviceLabel: Z-Wave Smart Plug + manufacturerId: 0x0312 + productId: 0xFF0C + productType: 0xFF00 + deviceProfileName: smartplug-binary - id: Evolve/RelaySwitch deviceLabel: Evolve Switch manufacturerId: 0x0113 From f46b6470978a6736ce634e01d054835a919d9284 Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Mon, 31 Aug 2026 13:04:02 -0500 Subject: [PATCH 08/31] Fix hard privacy mode presence in `supportedAttributes` (#3201) * Fix hard privacy mode presence in `supportedAttributes` * Make hardPrivacyMode independant of soft one --- .../camera_handlers/attribute_handlers.lua | 7 +- .../camera_utils/device_configuration.lua | 51 +++++- .../camera/camera_utils/fields.lua | 1 + .../src/test/test_matter_camera.lua | 163 +++++++++++++++++- 4 files changed, 206 insertions(+), 16 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua index 075f5cc487..075f9af600 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua @@ -18,8 +18,6 @@ CameraAttributeHandlers.enabled_state_factory = function(attribute) camera_utils.update_supported_attributes(device, ib, capabilities.imageControl, "imageFlipHorizontal") elseif attribute == capabilities.imageControl.imageFlipVertical then camera_utils.update_supported_attributes(device, ib, capabilities.imageControl, "imageFlipVertical") - elseif attribute == capabilities.cameraPrivacyMode.hardPrivacyMode then - camera_utils.update_supported_attributes(device, ib, capabilities.cameraPrivacyMode, "hardPrivacyMode") end end end @@ -450,7 +448,7 @@ end function CameraAttributeHandlers.camera_av_stream_management_attribute_list_handler(driver, device, ib, response) if not ib.data.elements then return end - local status_light_enabled_present, status_light_brightness_present = false, false + local status_light_enabled_present, status_light_brightness_present, hard_privacy_mode_present = false, false, false local attribute_ids = {} for _, attr in ipairs(ib.data.elements) do if attr.value == clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID then @@ -459,6 +457,8 @@ function CameraAttributeHandlers.camera_av_stream_management_attribute_list_hand elseif attr.value == clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID then status_light_brightness_present = true table.insert(attribute_ids, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + elseif attr.value == clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn.ID then + hard_privacy_mode_present = true end end local component_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} @@ -469,6 +469,7 @@ function CameraAttributeHandlers.camera_av_stream_management_attribute_list_hand } device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist=true}) camera_cfg.update_status_light_attribute_presence(device, status_light_enabled_present, status_light_brightness_present) + camera_cfg.update_hard_privacy_mode_attribute_presence(device, hard_privacy_mode_present) camera_cfg.reconcile_profile_and_capabilities(device) end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua index 7cb8edd930..282d33677f 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua @@ -33,6 +33,14 @@ local function set_status_light_presence(device, status_light_enabled_present, s device:set_field(camera_fields.STATUS_LIGHT_BRIGHTNESS_PRESENT, status_light_brightness_present == true, { persist = true }) end +local function get_hard_privacy_mode_presence(device) + return device:get_field(camera_fields.HARD_PRIVACY_MODE_PRESENT) +end + +local function set_hard_privacy_mode_presence(device, hard_privacy_mode_present) + device:set_field(camera_fields.HARD_PRIVACY_MODE_PRESENT, hard_privacy_mode_present == true, { persist = true }) +end + local function build_webrtc_supported_features() return { bundle = true, @@ -100,12 +108,32 @@ local function build_video_stream_settings_supported_features(device) return supported_features end -local function build_camera_privacy_supported_attributes() - return { "softRecordingPrivacyMode", "softLivestreamPrivacyMode" } +local function camera_privacy_feature_supported(device) + return camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.PRIVACY) end -local function build_camera_privacy_supported_commands() - return { "setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode" } +-- SoftRecordingPrivacyModeEnabled/SoftLivestreamPrivacyModeEnabled are conditionally mandatory on the PRIV +-- feature, while HardPrivacyModeOn has independent optional conformance. +-- A device can support either, both, or neither, so each is built independently. +local function build_camera_privacy_supported_attributes(device) + local supported_attributes = {} + if camera_privacy_feature_supported(device) then + table.insert(supported_attributes, "softRecordingPrivacyMode") + table.insert(supported_attributes, "softLivestreamPrivacyMode") + end + if get_hard_privacy_mode_presence(device) then + table.insert(supported_attributes, "hardPrivacyMode") + end + return supported_attributes +end + +local function build_camera_privacy_supported_commands(device) + local supported_commands = {} + if camera_privacy_feature_supported(device) then + table.insert(supported_commands, "setSoftRecordingPrivacyMode") + table.insert(supported_commands, "setSoftLivestreamPrivacyMode") + end + return supported_commands end local function capabilities_needing_reinit(device) @@ -155,8 +183,8 @@ local function capabilities_needing_reinit(device) capabilities_to_reinit.video_stream_settings = true end - if should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedAttributes, build_camera_privacy_supported_attributes()) or - should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedCommands, build_camera_privacy_supported_commands()) then + if should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedAttributes, build_camera_privacy_supported_attributes(device)) or + should_init(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedCommands, build_camera_privacy_supported_commands(device)) then capabilities_to_reinit.camera_privacy_mode = true end @@ -194,6 +222,7 @@ end function CameraDeviceConfiguration.match_profile(device) local status_light_enabled_present, status_light_brightness_present = get_status_light_presence(device) + local hard_privacy_mode_present = get_hard_privacy_mode_presence(device) local profile_update_requested = false local optional_supported_component_capabilities = {} local main_component_capabilities = {} @@ -234,7 +263,7 @@ function CameraDeviceConfiguration.match_profile(device) if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then table.insert(main_component_capabilities, capabilities.imageCapture.ID) end - if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.PRIVACY) then + if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.PRIVACY) or hard_privacy_mode_present then table.insert(main_component_capabilities, capabilities.cameraPrivacyMode.ID) end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SPEAKER) then @@ -361,8 +390,8 @@ end local function init_camera_privacy_mode(device) if device:supports_capability(capabilities.cameraPrivacyMode) then local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedAttributes(build_camera_privacy_supported_attributes())) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedCommands(build_camera_privacy_supported_commands())) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedAttributes(build_camera_privacy_supported_attributes(device))) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedCommands(build_camera_privacy_supported_commands(device))) end end @@ -442,6 +471,10 @@ function CameraDeviceConfiguration.update_status_light_attribute_presence(device set_status_light_presence(device, status_light_enabled_present, status_light_brightness_present) end +function CameraDeviceConfiguration.update_hard_privacy_mode_attribute_presence(device, hard_privacy_mode_present) + set_hard_privacy_mode_presence(device, hard_privacy_mode_present) +end + function CameraDeviceConfiguration.reinitialize_changed_camera_capabilities_and_subscriptions(device, old_profile, new_profile) local changed_capabilities = changed_capabilities_from_profiles(old_profile, new_profile) initialize_selected_camera_capabilities(device, changed_capabilities) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua index c88f177707..edb4deb125 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua @@ -16,6 +16,7 @@ CameraFields.TRIGGERED_ZONES = "__triggered_zones" CameraFields.DPTZ_VIEWPORTS = "__dptz_viewports" CameraFields.STATUS_LIGHT_ENABLED_PRESENT = "__status_light_enabled_present" CameraFields.STATUS_LIGHT_BRIGHTNESS_PRESENT = "__status_light_brightness_present" +CameraFields.HARD_PRIVACY_MODE_PRESENT = "__hard_privacy_mode_present" CameraFields.CameraAVSMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvStreamManagement.ID } CameraFields.CameraAVSULMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvSettingsUserLevelManagement.ID } diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua index 020b7bb906..52c5e7bb9c 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -800,6 +800,7 @@ test.register_coroutine_test( subscribe = function() subscribe_called = true end, supports_capability = function() return false end, get_endpoints = function() return { DOORBELL_EP } end, + get_field = function() return nil end, } local original_match_profile = camera_cfg.match_profile @@ -894,6 +895,9 @@ test.register_coroutine_test( get_endpoints = function() return { CAMERA_EP } end, + get_field = function() + return nil + end, emit_event_for_endpoint = function() init_event_count = init_event_count + 1 end @@ -909,6 +913,132 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Camera privacy mode should be added to profile when HardPrivacyModeOn is present even without the PRIV feature", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local updated_metadata = nil + local fake_device = { + profile = { components = {} }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + }, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, -- no PRIVACY feature + cluster_type = "SERVER" + } + } + } + }, + get_field = function(_, field) + return field == camera_fields.HARD_PRIVACY_MODE_PRESENT + end, + get_endpoints = function() return {} end, + try_update_metadata = function(_, metadata) updated_metadata = metadata end, + } + + camera_cfg.match_profile(fake_device) + + assert(updated_metadata ~= nil, "profile update should be requested when HardPrivacyModeOn is present") + local main_capabilities + for _, component in ipairs(updated_metadata.optional_component_capabilities) do + if component[1] == "main" then + main_capabilities = component[2] + end + end + local found = false + for _, cap_id in ipairs(main_capabilities or {}) do + if cap_id == capabilities.cameraPrivacyMode.ID then + found = true + end + end + assert(found, "cameraPrivacyMode should be added to the profile based on HardPrivacyModeOn presence alone") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Camera privacy mode supportedAttributes/supportedCommands should only expose hardPrivacyMode when PRIV feature is absent", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local emitted = {} + local fake_device = { + endpoints = { + { + endpoint_id = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, -- no PRIVACY feature + cluster_type = "SERVER" + } + } + } + }, + supports_capability = function(_, capability) + return capability == capabilities.cameraPrivacyMode + end, + get_field = function(_, field) + return field == camera_fields.HARD_PRIVACY_MODE_PRESENT + end, + get_endpoints = function(self, cluster_id, opts) + opts = opts or {} + local eps = {} + for _, ep in ipairs(self.endpoints) do + for _, clus in ipairs(ep.clusters) do + if clus.cluster_id == cluster_id and + (opts.feature_bitmap == nil or (clus.feature_map & opts.feature_bitmap) == opts.feature_bitmap) then + table.insert(eps, ep.endpoint_id) + end + end + end + return eps + end, + emit_event_for_endpoint = function(_, _, capability_event) + table.insert(emitted, capability_event) + end, + } + + camera_cfg.initialize_camera_capabilities(fake_device) + + local function list_equals(a, b) + if #a ~= #b then return false end + for i, v in ipairs(a) do + if v ~= b[i] then return false end + end + return true + end + + local supported_attributes_event, supported_commands_event + for _, event in ipairs(emitted) do + if event.attribute == capabilities.cameraPrivacyMode.supportedAttributes then + supported_attributes_event = event + elseif event.attribute == capabilities.cameraPrivacyMode.supportedCommands then + supported_commands_event = event + end + end + + assert(supported_attributes_event ~= nil, "supportedAttributes should be emitted") + assert(list_equals(supported_attributes_event.value.value, {"hardPrivacyMode"}), + "supportedAttributes should contain only hardPrivacyMode when PRIV feature is absent") + assert(supported_commands_event ~= nil, "supportedCommands should be emitted") + assert(list_equals(supported_commands_event.value.value, {}), + "supportedCommands should be empty when PRIV feature is absent, since HardPrivacyModeOn is read-only") + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Reports mapping to EnabledState capability data type should generate appropriate events", function() @@ -940,10 +1070,6 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal", "imageFlipVertical"})) ) - elseif v.capability == capabilities.cameraPrivacyMode.hardPrivacyMode then - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes({"softRecordingPrivacyMode", "softLivestreamPrivacyMode", "hardPrivacyMode"})) - ) end test.socket.matter:__queue_receive({ mock_device.id, @@ -3432,6 +3558,35 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Camera privacy mode supportedAttributes should include hardPrivacyMode when reported in AttributeList", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID), + uint32(clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn.ID) + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes( + {"softRecordingPrivacyMode", "softLivestreamPrivacyMode", "hardPrivacyMode"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedCommands( + {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} + )) + ) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Camera profile should include zoneManagement when USER_DEFINED feature is present", function() From 7d5651edf219681918d2dc769ef346db23f3e3bb Mon Sep 17 00:00:00 2001 From: Cooper Towns Date: Tue, 8 Sep 2026 12:15:55 -0500 Subject: [PATCH 09/31] Matter Switch: restrict Eve sub driver to specific PIDS (#3213) --- .../src/sub_drivers/eve_energy/can_handle.lua | 7 ++++++- .../matter-switch/src/switch_utils/fields.lua | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua index 02bc9e06d2..2bc395ae43 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/can_handle.lua @@ -10,9 +10,14 @@ return function(opts, driver, device) -- this sub driver loads for devices that: -- 1. Contain the Eve Private Cluster (0x130AFC01) -- 2. Do NOT have the Standard Electrical Sensor device type + -- 3. Match one of the known Eve Energy vendor/product ID combinations + -- We should still check that the device does not have the Electrical Sensor + -- device type because this sub driver is only needed for cases where devices + -- may be on old FW and do not support the standard Electical Sensor related clusters. if device.network_type == device_lib.NETWORK_TYPE_MATTER and #device:get_endpoints(EVE_PRIVATE_CLUSTER_ID) > 0 and - #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR) == 0 then + #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR) == 0 and + switch_utils.get_product_override_field(device, "needs_eve_energy_subdriver") then return true, require("sub_drivers.eve_energy") end return false diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index 5873cb40b2..d16214d2fd 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -126,6 +126,15 @@ SwitchFields.vendor_overrides = { [0x0007] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 1.1M [0x000A] = { needs_hager_subdriver = true }, -- Hager HBnet PIR 2.2M }, + [0x130A] = { -- EVE_MANUFACTURER_ID + [0x0050] = { needs_eve_energy_subdriver = true }, -- Eve Energy EU + [0x0053] = { needs_eve_energy_subdriver = true }, -- Eve Energy US + [0x0054] = { needs_eve_energy_subdriver = true }, -- Eve Energy UK + [0x005E] = { needs_eve_energy_subdriver = true }, -- Eve Energy AU + [0x0069] = { needs_eve_energy_subdriver = true }, -- Eve Energy Outlet US + [0x006A] = { needs_eve_energy_subdriver = true }, -- Eve Energy CH + [0x006B] = { needs_eve_energy_subdriver = true }, -- Eve Energy Outlet EU + }, [0x1321] = { -- SONOFF_MANUFACTURER_ID [0x000C] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, [0x000D] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, From 71bbd3da8178b1c38332a6d9342ea23a3ce5b3dd Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Tue, 8 Sep 2026 16:19:40 -0500 Subject: [PATCH 10/31] Handle nil in PTZ command for axis clamping (#3217) --- .../camera_handlers/capability_handlers.lua | 12 +++++++++--- .../src/test/test_matter_camera.lua | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua index 738d6d3556..ef52efcdbe 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua @@ -157,9 +157,15 @@ CameraCapabilityHandlers.ptz_set_position_factory = function(command) -- a single-axis command should only set (and clamp) the axis it targets. local pan, tilt, zoom if command == capabilities.mechanicalPanTiltZoom.commands.setPanTiltZoom then - pan = utils.clamp_value(cmd.args.pan, ptz_map[camera_fields.PAN_IDX].range.minimum, ptz_map[camera_fields.PAN_IDX].range.maximum) - tilt = utils.clamp_value(cmd.args.tilt, ptz_map[camera_fields.TILT_IDX].range.minimum, ptz_map[camera_fields.TILT_IDX].range.maximum) - zoom = utils.clamp_value(cmd.args.zoom, ptz_map[camera_fields.ZOOM_IDX].range.minimum, ptz_map[camera_fields.ZOOM_IDX].range.maximum) + if cmd.args.pan ~= nil then + pan = utils.clamp_value(cmd.args.pan, ptz_map[camera_fields.PAN_IDX].range.minimum, ptz_map[camera_fields.PAN_IDX].range.maximum) + end + if cmd.args.tilt ~= nil then + tilt = utils.clamp_value(cmd.args.tilt, ptz_map[camera_fields.TILT_IDX].range.minimum, ptz_map[camera_fields.TILT_IDX].range.maximum) + end + if cmd.args.zoom ~= nil then + zoom = utils.clamp_value(cmd.args.zoom, ptz_map[camera_fields.ZOOM_IDX].range.minimum, ptz_map[camera_fields.ZOOM_IDX].range.maximum) + end elseif command == capabilities.mechanicalPanTiltZoom.commands.setPan then pan = utils.clamp_value(cmd.args.pan, ptz_map[camera_fields.PAN_IDX].range.minimum, ptz_map[camera_fields.PAN_IDX].range.maximum) elseif command == capabilities.mechanicalPanTiltZoom.commands.setTilt then diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua index 52c5e7bb9c..e1467edecf 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -2352,6 +2352,24 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "Set PTZ command with a zoom-omitting setPanTiltZoom should not clamp the missing axis", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setPanTiltZoom", args = { 0, 0 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 0, 0, nil) + }) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Preset commands should send the appropriate commands", function() From 8d5cac5d8257a00ec0fd0d5568e2387b5b3299cd Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 8 Sep 2026 13:53:57 -0500 Subject: [PATCH 11/31] Adjust tests to handle trustworthy child_ids change --- .../src/test/test_aqara_light_switch_h2.lua | 11 +++------- .../src/test/test_hager_waasys.lua | 10 ++++++--- .../test_matter_multi_button_switch_mcd.lua | 22 +++++++++++-------- ..._aeotec_home_energy_meter_gen8_1_phase.lua | 3 ++- ..._aeotec_home_energy_meter_gen8_2_phase.lua | 3 ++- ..._aeotec_home_energy_meter_gen8_3_phase.lua | 3 ++- .../test/test_zwave_dual_switch_migration.lua | 12 ++++++---- 7 files changed, 37 insertions(+), 27 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua index 08402cf273..a78a2375c0 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua @@ -4,8 +4,6 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" -local utils = require "st.utils" -local dkjson = require "dkjson" local clusters = require "st.matter.clusters" local version = require "version" @@ -205,19 +203,16 @@ local function test_init() parent_assigned_child_key = string.format("%d", aqara_child2_ep) }) - local device_info_copy = utils.deep_copy(aqara_mock_device.raw_st_data) - device_info_copy.profile.id = "4-button" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "infoChanged", device_info_json }) - configure_buttons() - test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive(aqara_mock_device:generate_info_changed({profile = t_utils.get_profile_definition("4-button.yml")})) end + test.set_test_init_function(test_init) test.register_coroutine_test( "Button/Switch device : button/switch capability should send the appropriate commands", function() + test.wait_for_events() test.socket.matter:__queue_receive( { aqara_mock_device.id, diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua index a85137d0e3..4aeee85023 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -494,10 +494,13 @@ local function button_supported_values (matter_device) test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) end -local function initiate_info_changed(device, profile) +local function initiate_info_changed(device, profile, parent) test.socket.device_lifecycle:__queue_receive(device:generate_info_changed({ profile = { id = profile } })) test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.mock_time.advance_time(2) + if parent ~= nil then + test.socket.device_lifecycle:__queue_receive(parent:generate_info_changed({})) + end end local function configure_parent(device) @@ -1432,7 +1435,7 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot }) test.mock_device.add_test_device(child_dimmer) - initiate_info_changed(child_dimmer, "light-level") + initiate_info_changed(child_dimmer, "light-level", parent_pir) test.socket.matter:__expect_send({ parent_pir.id, cluster_base.subscribe(parent_pir, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) @@ -1558,7 +1561,7 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile }) test.mock_device.add_test_device(child_wc) - initiate_info_changed(child_wc, "window-covering") + initiate_info_changed(child_wc, "window-covering", parent) test.socket.matter:__expect_send({ parent.id, @@ -1725,6 +1728,7 @@ test.register_coroutine_test("Test: Window Covering - Preference Changes for Rev test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "false" } })) test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "true" } })) + test.socket.device_lifecycle():__queue_receive(parent:generate_info_changed({ })) test.wait_for_events() local reverse_preference_set = child_wc.preferences.reverse assert(reverse_preference_set == "true", "reverse_preference_set is True") diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua index b3390edc29..1c9e901277 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua @@ -419,17 +419,20 @@ test.register_coroutine_test( "Test driver switched event", function() test.mock_device.add_test_device(mock_child) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) + test.wait_for_events() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_WITH_CHILD[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_WITH_CHILD) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) - mock_child:expect_metadata_update({ profile = "light-color-level" }) - mock_device:expect_metadata_update({ profile = "light-level-3-button" }) - expect_configure_buttons() - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_WITH_CHILD) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + mock_child:expect_metadata_update({ profile = "light-color-level" }) + mock_device:expect_metadata_update({ profile = "light-level-3-button" }) + expect_configure_buttons() + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, { min_api_version = 15 @@ -475,6 +478,7 @@ test.register_coroutine_test( test.socket.matter:__expect_send({mock_device.id, clusters.OnOff.attributes.OnOff:read(mock_device)}) test.socket.device_lifecycle:__queue_receive({ mock_child.id, "added" }) test.socket.device_lifecycle:__queue_receive({ mock_child.id, "init" }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) local subscribe_request = CLUSTER_SUBSCRIBE_LIST_WITH_CHILD[1]:subscribe(mock_device) for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_WITH_CHILD) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua index f581f457df..484ba90c7c 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua @@ -128,7 +128,8 @@ test.register_coroutine_test( ) end end - end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} ) test.register_coroutine_test( diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua index 43ea5cdbe3..7ab752246a 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua @@ -128,7 +128,8 @@ test.register_coroutine_test( ) end end - end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} ) test.register_coroutine_test( diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua index 465e3add9f..c1fedbf717 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua @@ -128,7 +128,8 @@ test.register_coroutine_test( ) end end - end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} ) test.register_coroutine_test( diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua index ef2e4997b2..9b5328a748 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch_migration.lua @@ -36,12 +36,15 @@ local mock_parent = test.mock_device.build_test_zwave_device({ zwave_manufacturer_id = 0x0086, zwave_product_type = 0x0103, zwave_product_id = 0x008C, - child_ids = { - "abcdefghijklmnopq", - "12345678910111213" - } }) +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("switch-binary.yml"), + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + + local mock_parent_no_data = test.mock_device.build_test_zwave_device({ label = "Aeotec Switch 1", profile = t_utils.get_profile_definition("switch-binary.yml"), @@ -53,6 +56,7 @@ local mock_parent_no_data = test.mock_device.build_test_zwave_device({ local function test_init() test.mock_device.add_test_device(mock_parent) + test.mock_device.add_test_device(mock_child) test.mock_device.add_test_device(mock_parent_no_data) end From 37e3766d9366b50f0c5724c2dcbb8a3b80e9441c Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Thu, 10 Sep 2026 13:23:54 -0500 Subject: [PATCH 12/31] WWSTCERT-13643 Linkind Light Stick T19 (#3221) --- drivers/SmartThings/matter-switch/fingerprints.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 4590ba0a74..64c5e17dde 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -231,6 +231,16 @@ matterManufacturer: vendorId: 0x1396 productId: 0x103E deviceProfileName: fan-modular + - id: "5014/4576" + deviceLabel: Linkind Light Stick T19 + vendorId: 0x1396 + productId: 0x11E0 + deviceProfileName: light-color-level + - id: "5014/4574" + deviceLabel: Linkind Light Stick T19 + vendorId: 0x1396 + productId: 0x11DE + deviceProfileName: light-color-level #Bosch Smart Home - id: "4617/12310" deviceLabel: Plug Compact [M] From ea9d3f45d14c359a4dbb4b32a8f17d243a7a3553 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Thu, 10 Sep 2026 15:18:26 -0500 Subject: [PATCH 13/31] add CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..eef4bd20cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file From 19bb6f9b75a4a7590dfb5c5f9aed3bbf3308c77c Mon Sep 17 00:00:00 2001 From: HunsupJung <59987061+HunsupJung@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:00:30 +0900 Subject: [PATCH 14/31] Fix addUser() -> addCredential() issue (#3220) Signed-off-by: Hunsup Jung --- drivers/SmartThings/matter-lock/src/init.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/drivers/SmartThings/matter-lock/src/init.lua b/drivers/SmartThings/matter-lock/src/init.lua index f94702c496..72bfaae425 100755 --- a/drivers/SmartThings/matter-lock/src/init.lua +++ b/drivers/SmartThings/matter-lock/src/init.lua @@ -1313,7 +1313,10 @@ local function get_user_response_handler(driver, device, ib, response) end local ep = find_default_endpoint(device, DoorLock.ID) - local status = elements.user_status.value + local status = nil + if elements.user_status ~= nil then + status = elements.user_status.value + end local maxUser = device:get_latest_state( "main", capabilities.lockUsers.ID, @@ -1624,11 +1627,11 @@ local function set_pin_response_handler(driver, device, ib, response) -- If user is added also, update User table if userIdx == nil then + userIdx = elements.user_index.value add_user_to_table(device, elements.user_index.value, nil, userType) end -- Update Credential table - userIdx = elements.user_index.value if cmdName == "addCredential" then add_credential_to_table(device, userIdx, credIdx, "pin") end From 80e5285f037489a64a2f2f9a3b635814a482b06f Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Mon, 14 Sep 2026 11:11:03 -0500 Subject: [PATCH 15/31] WWSTCERT-13701 Lowes - Utilitech A19 Smart Bulb (#3228) --- drivers/SmartThings/matter-switch/fingerprints.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 64c5e17dde..4929f42c08 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -4280,6 +4280,12 @@ matterManufacturer: vendorId: 0x164B productId: 0x0001 deviceProfileName: light-color-level +#Utilitech + - id: "5732/2" + deviceLabel: Lowes - Utilitech A19 Smart Bulb + vendorId: 0x1664 + productId: 0x0002 + deviceProfileName: light-color-level matterGeneric: From 6434162b0af004c3c1baf180a47ec739245ee7fe Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Tue, 15 Sep 2026 10:03:06 -0500 Subject: [PATCH 16/31] WWSTCERT-13745 Govee Christmas 2-in-1 String Lights (#3234) --- drivers/SmartThings/matter-switch/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 4929f42c08..4247d1699b 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -958,6 +958,11 @@ matterManufacturer: vendorId: 0x1387 productId: 0x2A40 deviceProfileName: light-color-level + - id: "4999/25168" + deviceLabel: Govee Christmas 2-in-1 String Lights + vendorId: 0x1387 + productId: 0x6250 + deviceProfileName: light-color-level # Hager - id: "4741/8" deviceLabel: Hager matter 2 buttons (battery) From 87c61ada4858f37beb03a12550f58a5376e016ce Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Tue, 15 Sep 2026 11:00:02 -0500 Subject: [PATCH 17/31] WWSTCERT-13738 Utilitech 17 Smart Flush Mount - Full Color (#3235) --- drivers/SmartThings/matter-switch/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 4247d1699b..46120cdc59 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -4291,6 +4291,11 @@ matterManufacturer: vendorId: 0x1664 productId: 0x0002 deviceProfileName: light-color-level + - id: "5732/1536" + deviceLabel: Utilitech 17 Smart Flush Mount - Full Color + vendorId: 0x1664 + productId: 0x0600 + deviceProfileName: light-color-level matterGeneric: From 1673928e793bd952f53597a99c6aac282df34d6a Mon Sep 17 00:00:00 2001 From: Alec Lorimer Date: Wed, 16 Sep 2026 16:06:15 -0500 Subject: [PATCH 18/31] Updated CredentialId for CN Prod --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 752d3d358c..cedacdf64d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -39,7 +39,7 @@ def getDockerCredentialId() { def nodeLabel = params.NODE_LABEL ?: 'production' def region = get_region() if (nodeLabel == 'production' && region == 'cn') { - return 'artifactory-credentials' + return 'artifactory-cn' } else { return '' From 3f8f6e5ed7350115b55f2bf6d2360a4483a8eb73 Mon Sep 17 00:00:00 2001 From: "Jan Jakubiszyn/SmartThings Development (BJ.jakubiszynS) /SRPOL/Engineer/Samsung Electronics" Date: Tue, 1 Sep 2026 12:26:40 +0200 Subject: [PATCH 19/31] Fix hager driver delay # Conflicts: # drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua --- .../src/sub_drivers/hager/init.lua | 6 +- .../src/test/test_hager_waasys.lua | 366 ++++++++++++++++-- 2 files changed, 329 insertions(+), 43 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua index db8cfe67d4..acfaa10871 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -189,10 +189,10 @@ local function info_changed(driver, device, event, args) end if device.profile.id ~= args.old_st_store.profile.id or device.network_type == device_lib.NETWORK_TYPE_CHILD then - local parent = device:get_parent_device() - local matter_device = get_matter_device(parent) - local map = {} device.thread:call_with_delay(2, function() + local parent = device:get_parent_device() + local matter_device = get_matter_device(parent) + local map = {} if device:supports_capability(capabilities.button) then local button_eps = parent:get_field(BUTTON_EPS) local clean_eps = {} diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua index 4aeee85023..072229ffae 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -17,6 +17,7 @@ end local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" local PARENT_ID = "PARENT_ID" local BUTTON_EPS = "__button_eps" +local MAIN_WC_EP = "__main_wc_ep" local function create_parent_device(product_id) return test.mock_device.build_test_matter_device({ @@ -434,6 +435,80 @@ local function create_hager_pir_device(profile_name, parent) }) end +-- Create Hager 1G device with ONLY a WindowCovering endpoint (12) - no button or occupancy endpoints +local function create_hager_1g_window_covering(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 1G Window Covering", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.WindowCovering.types.Feature.LIFT | + clusters.WindowCovering.types.Feature.POSITION_AWARE_LIFT | + clusters.WindowCovering.types.Feature.ABSOLUTE_POSITION, + attributes = { + [clusters.WindowCovering.attributes.OperationalStatus.ID] = 0x00, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = 0x0000, + } + } + }, + device_types = { { device_type_id = 0x0202, device_type_revision = 1 } } + }, + } + }) +end + +-- Create Hager 1G Relay device with endpoint 3 (OnOff cluster) +local function create_hager_1g_relay(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 1G Relay", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + } + }) +end + local function add_parent_device(parent) test.mock_device.add_test_device(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) @@ -494,13 +569,10 @@ local function button_supported_values (matter_device) test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) end -local function initiate_info_changed(device, profile, parent) - test.socket.device_lifecycle:__queue_receive(device:generate_info_changed({ profile = { id = profile } })) +local function initiate_info_changed(device, profile) test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.device_lifecycle:__queue_receive(device:generate_info_changed({ profile = { id = profile } })) test.mock_time.advance_time(2) - if parent ~= nil then - test.socket.device_lifecycle:__queue_receive(parent:generate_info_changed({})) - end end local function configure_parent(device) @@ -515,6 +587,50 @@ local function configure_parent(device) test.mock_time.advance_time(3) end +-- Create window covering child device on EP12 via PartsList and DeviceTypeList reports +local function announce_window_covering_child(host) + test.socket.matter:__queue_receive({ + host.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(host, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + for _, ep in ipairs({ 8, 9, 12 }) do + test.socket.matter:__expect_send({ + host.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(host, ep) + }) + end + + test.socket.matter:__queue_receive({ + host.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(host, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + host:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = host.id, + parent_assigned_child_key = "12" + }) + + local child_wc = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("window-covering.yml"), + device_network_id = string.format("%s:12", host.id), + parent_device_id = host.id, + parent_assigned_child_key = "12" + }) + test.mock_device.add_test_device(child_wc) + return child_wc +end + local parent = create_parent_device(0x0006) -- 2G button product local parent_1g = create_parent_device(0x0005) -- 1G button product local parent_pir = create_parent_device(0x0007) -- PIR product @@ -596,9 +712,9 @@ test.register_coroutine_test("Test: 4-Button Device Detection - Profile Changes }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press, and Held Events on 4-Button Device", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -803,9 +919,9 @@ test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press }) test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.held({ state_change = true }))) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 15) and OnOff (Type 256) Device Types with Child Creation", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -899,9 +1015,9 @@ test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 1 clusters.OnOff.attributes.OnOff:subscribe(parent, nil) }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-binary and 2-button Based On Endpoint Availability", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1144,9 +1260,9 @@ test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-bin parent_assigned_child_key = "4" }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable Endpoint with Button Support", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1243,9 +1359,9 @@ test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Update to light-level", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1255,9 +1371,9 @@ test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Updat test.wait_for_events() configure_parent(parent) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control Capabilities", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1330,9 +1446,9 @@ test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control clusters.LevelControl.server.commands.MoveToLevelWithOnOff(dimmer, 4, 51, nil, 0, 0) }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: PIR Device - Initialization with Motion and Illuminance Capabilities", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1343,9 +1459,9 @@ test.register_coroutine_test("Test: PIR Device - Initialization with Motion and configure_parent(parent_pir) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: PIR Device - Complete Functionality with Motion, Illuminance, and Dimmer Support", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1435,7 +1551,7 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot }) test.mock_device.add_test_device(child_dimmer) - initiate_info_changed(child_dimmer, "light-level", parent_pir) + initiate_info_changed(child_dimmer, "light-level") test.socket.matter:__expect_send({ parent_pir.id, cluster_base.subscribe(parent_pir, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) @@ -1500,9 +1616,9 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(50))) parent_pir.expect_native_attr_handler_registration(parent_pir, "switchLevel", "level") end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile with Window Covering Child Device", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1561,7 +1677,7 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile }) test.mock_device.add_test_device(child_wc) - initiate_info_changed(child_wc, "window-covering", parent) + initiate_info_changed(child_wc, "window-covering") test.socket.matter:__expect_send({ parent.id, @@ -1641,9 +1757,9 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Window Covering - Preference Changes for Reverse Polarity and Preset Position", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1728,7 +1844,6 @@ test.register_coroutine_test("Test: Window Covering - Preference Changes for Rev test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "false" } })) test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "true" } })) - test.socket.device_lifecycle():__queue_receive(parent:generate_info_changed({ })) test.wait_for_events() local reverse_preference_set = child_wc.preferences.reverse assert(reverse_preference_set == "true", "reverse_preference_set is True") @@ -1810,8 +1925,179 @@ test.register_coroutine_test("Test: Window Covering - Preference Changes for Rev cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) }) end, - { - min_api_version = 15 + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: 1G Window Covering - window-covering Profile and No Duplicate Child Device", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_1g) + local wc_1g = create_hager_1g_window_covering("window-covering", parent_1g) + add_matter_device(wc_1g, parent_1g, "window-covering") + test.wait_for_events() + configure_parent(parent_1g) + test.wait_for_events() + + local main_wc_ep = wc_1g:get_field(MAIN_WC_EP) + assert(main_wc_ep == 12, "MAIN_WC_EP is set to 12") + + -- EP12 reported again → no second child created, MAIN_WC_EP already set + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(12), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 12) + }) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } }) + }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: 1G Relay - Profile Changes Between light-binary and 2-button Based On EP3 Availability", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_1g) + local relay_1g = create_hager_1g_relay("light-binary", parent_1g) + add_switch_matter_device(relay_1g, parent_1g) + test.wait_for_events() + configure_parent(parent_1g) + test.wait_for_events() + + -- Scenario 1: EP3 present → light-binary profile + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(3), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 3) + }) + relay_1g:expect_metadata_update({ profile = "light-binary" }) + test.wait_for_events() + + -- EP3 is device type 256 (OnOff) - no child device created for 1G + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 3, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + test.wait_for_events() + + -- Scenario 2: EP3 removed, buttons 8 & 9 present → 2-button profile + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 8) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 9) + }) + relay_1g:expect_metadata_update({ profile = "2-button" }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Window Covering - Position Attribute Reports With Reverse Polarity", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + local child_wc = announce_window_covering_child(parent) + test.wait_for_events() + + -- Attribute reports are addressed to the host, so reverse is set on the host + test.socket.device_lifecycle():__queue_receive( + parent:generate_info_changed({ preferences = { reverse = "true" } }) + ) + test.wait_for_events() + + -- Verify 50% position via attribute report - direction independent + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 5000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) + test.wait_for_events() + + -- Verify 100% position via attribute report - with reverse true, this should report closed + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 0) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) + test.wait_for_events() + + -- Verify 0% position via attribute report - with reverse true, this should report open + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 10000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.open())) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Window Covering - Unrecognised OperationalStatus Reports Unknown State", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + local child_wc = announce_window_covering_child(parent) + test.wait_for_events() + + -- OperationalStatus 0x03 is neither opening (1) nor closing (2) + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x03) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.unknown())) + test.wait_for_events() +end, + { + min_api_version = 15 + }) test.run_registered_tests() From 7be19456552364148bc2bd0f687cebf237af0ce3 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Mon, 21 Sep 2026 10:23:13 -0500 Subject: [PATCH 20/31] WWSTCERT-13529 SwitchBot RGBIC Wire Neon Rope Light (#3208) * WWSTCERT-13529 SwitchBot RGBIC Wire Neon Rope Light * Update device label for SwitchBot RGBIC Wire Light --- drivers/SmartThings/matter-switch/fingerprints.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 46120cdc59..b756cfad60 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -4253,6 +4253,16 @@ matterManufacturer: vendorId: 0x1397 productId: 0x07E7 deviceProfileName: matter-bridge + - id: "5015/2040" + deviceLabel: SwitchBot Color Lighting + vendorId: 0x1397 + productId: 0x07F8 + deviceProfileName: light-color-level + - id: "5015/2039" + deviceLabel: SwitchBot Floor Lamp + vendorId: 0x1397 + productId: 0x07F7 + deviceProfileName: light-color-level - id: "Nature/Bridge" deviceLabel: Matter Bridge vendorId: 0x138A From db0ec25fac22e1681445ee0530759544ec7ac042 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Wed, 9 Sep 2026 13:22:13 -0500 Subject: [PATCH 21/31] Update pairs table emptiness checks --- .../switch_handlers/attribute_handlers.lua | 16 +-- .../matter-switch/src/switch_utils/utils.lua | 11 ++- .../src/test/test_electrical_sensor_set.lua | 99 +++++++++++++++++++ 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index ae1f219606..46338233a0 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -325,12 +325,12 @@ end --- SET feature, all AvailableEndpoints responses must be handled before profiling. function AttributeHandlers.available_endpoints_handler(driver, device, ib, response) if device:get_field(fields.profiling_data.POWER_TOPOLOGY) ~= nil then - device.log.warn("Received an AvailableEndpoints response after power topology has already been determined. Ignoring this response.") + device.log.warn_with({hub_logs=true},"Received an AvailableEndpoints response after power topology has already been determined. Ignoring this response.") return end local set_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) if set_topology_eps == nil then - device.log.warn("Received an AvailableEndpoints response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with SET feature. Ignoring this response.") + device.log.warn_with({hub_logs=true},"Received an AvailableEndpoints response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with SET feature. Ignoring this response.") return end @@ -349,7 +349,8 @@ function AttributeHandlers.available_endpoints_handler(driver, device, ib, respo break end end - if #set_topology_eps == 0 then -- in other words, all AvailableEndpoints attribute responses have been handled + if switch_utils.is_field_empty(device, fields.ELECTRICAL_SENSOR_EPS) then + device.log.info_with({hub_logs=true}, "All AvailableEndpoints attribute responses for SET Electrical Sensor endpoints have been handled, attempting to match profile") device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.SET_TOPOLOGY, {persist=true}) device_cfg.match_profile(driver, device) end @@ -360,16 +361,16 @@ end function AttributeHandlers.parts_list_handler(driver, device, ib, response) if device:get_field(fields.profiling_data.POWER_TOPOLOGY) ~= nil then - device.log.warn("Received a PartsList response after power topology has already been determined. Ignoring this response.") + device.log.warn_with({hub_logs=true}, "Received a PartsList response after power topology has already been determined. Ignoring this response.") return end local tree_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) if tree_topology_eps == nil then - device.log.warn("Received a PartsList response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with TREE feature. Ignoring this response.") + device.log.warn_with({hub_logs=true}, "Received a PartsList response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with TREE feature. Ignoring this response.") return end - device.log.debug_with({hub_logs=true}, string.format("Handling PartsList response for endpoint %d with elements: %s", ib.endpoint_id, st_utils.stringify_table(ib.data.elements or {}))) + device.log.info_with({hub_logs=true}, string.format("Handling PartsList response for endpoint %d with elements: %s", ib.endpoint_id, st_utils.stringify_table(ib.data.elements or {}))) for i, tree_ep_info in pairs(tree_topology_eps or {}) do if ib.endpoint_id == tree_ep_info.endpoint_id then -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table @@ -384,7 +385,8 @@ function AttributeHandlers.parts_list_handler(driver, device, ib, response) break end end - if #tree_topology_eps == 0 then -- in other words, all PartsList attribute responses for TREE Electrical Sensor EPs have been handled + if switch_utils.is_field_empty(device, fields.ELECTRICAL_SENSOR_EPS) then + device.log.info_with({hub_logs=true}, "All PartsList attribute responses for TREE Electrical Sensor endpoints have been handled, attempting to match profile") device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, {persist=true}) device_cfg.match_profile(driver, device) end diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 1d12f41781..cb95ff770b 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -53,11 +53,20 @@ end function utils.remove_field_index(device, field_name, index) local new_table = device:get_field(field_name) if type(new_table) == "table" then - new_table[index] = nil -- remove value associated with index from table + new_table[index] = nil -- remove value associated with index from table without altering table structure device:set_field(field_name, new_table) end end +function utils.is_field_empty(device, field_name) + local field = device:get_field(field_name) + if type(field) == "table" then + return next(field) == nil + else + return field == nil + end +end + function utils.mired_to_kelvin(value, minOrMax) if value == 0 then -- shouldn't happen, but has value = 1 diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua index 32989187fa..18074c9b4e 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua @@ -112,6 +112,59 @@ local mock_device_periodic = test.mock_device.build_test_matter_device({ }, }) +--- Models an outlet of a multi-outlet power strip, which combines the Electrical Sensor and +--- OnOff Plug In Unit device types on a single endpoint. Note that the Electrical Sensor device +--- type is listed first, so it is the endpoint's primary device type and no profile is mapped to it. +local function build_power_strip_outlet_endpoint(endpoint_id) + return { + endpoint_id = endpoint_id, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 1, }, + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER", feature_map = 2, }, + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 15, }, + { cluster_id = clusters.PowerTopology.ID, cluster_type = "SERVER", feature_map = 4, }, -- SET_TOPOLOGY + }, + device_types = { + { device_type_id = 0x0510, device_type_revision = 1 }, -- Electrical Sensor + { device_type_id = 0x010A, device_type_revision = 1 }, -- OnOff Plug In Unit + } + } +end + +--- A 4-outlet power strip that reports its endpoints out of numerical order, as the Tapo +--- P304M does. The order in which the AvailableEndpoints reports are handled must not affect +--- profiling: every Electrical Sensor endpoint has to be accounted for before profiles are matched. +local mock_device_power_strip = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-power-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x1392, + product_id = 0x010F, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + build_power_strip_outlet_endpoint(1), + build_power_strip_outlet_endpoint(3), + build_power_strip_outlet_endpoint(4), + build_power_strip_outlet_endpoint(2), + }, +}) + +local subscribed_attributes_power_strip = { + clusters.OnOff.attributes.OnOff, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.PowerTopology.attributes.AvailableEndpoints, +} + local subscribed_attributes_periodic = { clusters.OnOff.attributes.OnOff, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, @@ -201,6 +254,17 @@ local function test_init_periodic() test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) end +local function test_init_power_strip() + test.mock_device.add_test_device(mock_device_power_strip) + local subscribe_request = subscribed_attributes_power_strip[1]:subscribe(mock_device_power_strip) + for i, cluster in ipairs(subscribed_attributes_power_strip) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_power_strip)) + end + end + test.socket.matter:__expect_send({ mock_device_power_strip.id, subscribe_request }) +end + test.register_message_test( "Active power measurement should generate correct messages", { @@ -711,4 +775,39 @@ test.register_message_test( } ) +test.register_coroutine_test( + "Profiling of a power strip must wait for the AvailableEndpoints report of every Electrical Sensor endpoint", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_power_strip.id, "doConfigure" }) + mock_device_power_strip:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + -- the reports arrive in endpoint order, which does not match the order that the endpoints + -- were reported in during the interview. Each outlet is the only endpoint in its own power set. + for _, endpoint_id in ipairs({1, 2, 3, 4}) do + test.socket.matter:__queue_receive({ + mock_device_power_strip.id, + clusters.PowerTopology.attributes.AvailableEndpoints:build_test_report_data( + mock_device_power_strip, endpoint_id, {uint32(endpoint_id)} + ) + }) + end + -- every outlet supports both power and energy measurement, so none of them should fall back + -- to the generic "switch-binary" profile used for an OnOff endpoint without electrical tags + for _, endpoint_id in ipairs({2, 3, 4}) do + mock_device_power_strip:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("nil %d", endpoint_id), + profile = "plug-power-energy-powerConsumption", + parent_device_id = mock_device_power_strip.id, + parent_assigned_child_key = string.format("%d", endpoint_id) + }) + end + mock_device_power_strip:expect_metadata_update({ profile = "plug-power-energy-powerConsumption" }) + end, + { + test_init = test_init_power_strip, + min_api_version = 14 + } +) + test.run_registered_tests() From 31cd2c97bb36a10e8671df9e57701c624aa86f33 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Tue, 22 Sep 2026 10:56:49 -0500 Subject: [PATCH 22/31] WWSTCERT-13832 Blinds motor (#3244) --- drivers/SmartThings/matter-window-covering/fingerprints.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/drivers/SmartThings/matter-window-covering/fingerprints.yml b/drivers/SmartThings/matter-window-covering/fingerprints.yml index 34406cf01c..66b33f0bf7 100644 --- a/drivers/SmartThings/matter-window-covering/fingerprints.yml +++ b/drivers/SmartThings/matter-window-covering/fingerprints.yml @@ -255,6 +255,12 @@ matterManufacturer: vendorId: 0x1523 productId: 0x0A69 deviceProfileName: window-covering-battery +#3L Smart + - id: "5412/48897" + deviceLabel: Blinds motor + vendorId: 0x1524 + productId: 0xBF01 + deviceProfileName: window-covering-battery matterGeneric: - id: "windowcovering" deviceLabel: Matter Window Covering From 4a77e6fab7a3eecdc5a47807ef911a56a2ef2f4b Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Tue, 22 Sep 2026 10:57:54 -0500 Subject: [PATCH 23/31] WWSTCERT-13815 Utilitech 1x4 Smart Panel - Full Color (#3243) --- drivers/SmartThings/matter-switch/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index b756cfad60..fd2a35c321 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -4306,6 +4306,11 @@ matterManufacturer: vendorId: 0x1664 productId: 0x0600 deviceProfileName: light-color-level + - id: "5732/768" + deviceLabel: Utilitech 1x4 Smart Panel - Full Color + vendorId: 0x1664 + productId: 0x0300 + deviceProfileName: light-color-level matterGeneric: From 5703703e63cf132d0ee4cca86e6d8f8b369fa850 Mon Sep 17 00:00:00 2001 From: NoahCornell Date: Tue, 22 Sep 2026 17:10:13 -0500 Subject: [PATCH 24/31] sonos: fix offline grouped speakers --- .../SmartThings/sonos/src/api/sonos_connection.lua | 11 +++++++---- .../sonos/src/api/sonos_websocket_router.lua | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/drivers/SmartThings/sonos/src/api/sonos_connection.lua b/drivers/SmartThings/sonos/src/api/sonos_connection.lua index 6a131c3b0c..4852fc517c 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_connection.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_connection.lua @@ -547,6 +547,8 @@ function SonosConnection.new(driver, device) self.on_close = function(_) log.debug(string.format("OnClose for %s", device_name)) if self._initialized then + self._initialized = false + log.debug(string.format("Marking %s as offline due to websocket closure", device_name)) self.device:offline() end if self._keepalive then @@ -566,10 +568,10 @@ function SonosConnection:is_running() string.format( "%s all connections running? %s", self.device.label, - st_utils.stringify_table({ coordinator = self_running, mine = self_running }) + st_utils.stringify_table({ coordinator = coord_running, mine = self_running }) ) ) - return self_running and coord_running + return self_running and coord_running and self._initialized end --- Whether or not the connection has a live websocket connection @@ -586,7 +588,7 @@ function SonosConnection:self_running() ) ) end - return type(unique_key) == "string" and Router.is_connected(unique_key) and self._initialized + return type(unique_key) == "string" and Router.is_connected(unique_key) end --- Whether or not the connection has a live websocket connection to its coordinator @@ -685,7 +687,7 @@ function SonosConnection:start() end if not self:coordinator_running() then - --TODO this is not infallible + --TODO this is not infallible, but is checked below _open_coordinator_socket(self, household_id, player_id, api_key) end @@ -700,6 +702,7 @@ function SonosConnection:start() and coordinator_unique_key and Router.is_connected(coordinator_unique_key) then + log.debug(string.format("Marking %s as online since all websockets connected", self.device.label)) self.device:online() self._initialized = true self._keepalive = true diff --git a/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua b/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua index c19e35274b..9ba4710a66 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_websocket_router.lua @@ -233,11 +233,11 @@ end ---@return string? function SonosWebSocketRouter.register_listener_for_socket(listener, unique_key_for_socket) if listener and listener.device and listener.device.label then - log.debug( + log.debug(string.format( "Registering SonosConnection for device %s as listener for player %s websocket", listener.device.label, unique_key_for_socket - ) + )) end local ws = websockets[unique_key_for_socket] From 5183653601bf94bbb68887b331f652fd5558c630 Mon Sep 17 00:00:00 2001 From: JanJakubiszyn <101173721+JanJakubiszyn@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:59:50 +0200 Subject: [PATCH 25/31] Revert "Fix hager driver delay" (#3247) --- .../src/sub_drivers/hager/init.lua | 6 +- .../src/test/test_hager_waasys.lua | 366 ++---------------- 2 files changed, 43 insertions(+), 329 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua index acfaa10871..db8cfe67d4 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -189,10 +189,10 @@ local function info_changed(driver, device, event, args) end if device.profile.id ~= args.old_st_store.profile.id or device.network_type == device_lib.NETWORK_TYPE_CHILD then + local parent = device:get_parent_device() + local matter_device = get_matter_device(parent) + local map = {} device.thread:call_with_delay(2, function() - local parent = device:get_parent_device() - local matter_device = get_matter_device(parent) - local map = {} if device:supports_capability(capabilities.button) then local button_eps = parent:get_field(BUTTON_EPS) local clean_eps = {} diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua index 072229ffae..4aeee85023 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -17,7 +17,6 @@ end local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" local PARENT_ID = "PARENT_ID" local BUTTON_EPS = "__button_eps" -local MAIN_WC_EP = "__main_wc_ep" local function create_parent_device(product_id) return test.mock_device.build_test_matter_device({ @@ -435,80 +434,6 @@ local function create_hager_pir_device(profile_name, parent) }) end --- Create Hager 1G device with ONLY a WindowCovering endpoint (12) - no button or occupancy endpoints -local function create_hager_1g_window_covering(profile_name, parent) - return test.mock_device.build_test_matter_device({ - label = "Hager G2 1G Window Covering", - profile = t_utils.get_profile_definition(profile_name .. ".yml"), - type = "MATTER", - manufacturer_info = { - vendor_id = 0x1285, - product_id = 0x0005, - }, - parent_device_id = parent.id, - endpoints = { - { - endpoint_id = 0, - clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, - device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } - }, - { - endpoint_id = 12, - clusters = { - { - cluster_id = clusters.WindowCovering.ID, - cluster_type = "SERVER", - cluster_revision = 1, - feature_map = clusters.WindowCovering.types.Feature.LIFT | - clusters.WindowCovering.types.Feature.POSITION_AWARE_LIFT | - clusters.WindowCovering.types.Feature.ABSOLUTE_POSITION, - attributes = { - [clusters.WindowCovering.attributes.OperationalStatus.ID] = 0x00, - [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = 0x0000, - } - } - }, - device_types = { { device_type_id = 0x0202, device_type_revision = 1 } } - }, - } - }) -end - --- Create Hager 1G Relay device with endpoint 3 (OnOff cluster) -local function create_hager_1g_relay(profile_name, parent) - return test.mock_device.build_test_matter_device({ - label = "Hager G2 1G Relay", - profile = t_utils.get_profile_definition(profile_name .. ".yml"), - type = "MATTER", - manufacturer_info = { - vendor_id = 0x1285, - product_id = 0x0005, - }, - parent_device_id = parent.id, - endpoints = { - { - endpoint_id = 0, - clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, - device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } - }, - { - endpoint_id = 3, - clusters = { - { - cluster_id = clusters.OnOff.ID, - cluster_type = "SERVER", - cluster_revision = 1, - attributes = { - [clusters.OnOff.attributes.OnOff.ID] = false - } - } - }, - device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } - }, - } - }) -end - local function add_parent_device(parent) test.mock_device.add_test_device(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) @@ -569,10 +494,13 @@ local function button_supported_values (matter_device) test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) end -local function initiate_info_changed(device, profile) - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") +local function initiate_info_changed(device, profile, parent) test.socket.device_lifecycle:__queue_receive(device:generate_info_changed({ profile = { id = profile } })) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.mock_time.advance_time(2) + if parent ~= nil then + test.socket.device_lifecycle:__queue_receive(parent:generate_info_changed({})) + end end local function configure_parent(device) @@ -587,50 +515,6 @@ local function configure_parent(device) test.mock_time.advance_time(3) end --- Create window covering child device on EP12 via PartsList and DeviceTypeList reports -local function announce_window_covering_child(host) - test.socket.matter:__queue_receive({ - host.id, - clusters.Descriptor.attributes.PartsList:build_test_report_data(host, 2, data_types.Array({ - data_types.Uint16(8), - data_types.Uint16(9), - data_types.Uint16(12), - })) - }) - for _, ep in ipairs({ 8, 9, 12 }) do - test.socket.matter:__expect_send({ - host.id, - clusters.Descriptor.attributes.DeviceTypeList:subscribe(host, ep) - }) - end - - test.socket.matter:__queue_receive({ - host.id, - clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(host, 12, data_types.Array { - { - device_type = data_types.Uint32(514), - revision = data_types.Uint16(1) - } - }) - }) - host:expect_device_create({ - type = "EDGE_CHILD", - label = "Hager G2 4x Button 1", - profile = "window-covering", - parent_device_id = host.id, - parent_assigned_child_key = "12" - }) - - local child_wc = test.mock_device.build_test_child_device({ - profile = t_utils.get_profile_definition("window-covering.yml"), - device_network_id = string.format("%s:12", host.id), - parent_device_id = host.id, - parent_assigned_child_key = "12" - }) - test.mock_device.add_test_device(child_wc) - return child_wc -end - local parent = create_parent_device(0x0006) -- 2G button product local parent_1g = create_parent_device(0x0005) -- 1G button product local parent_pir = create_parent_device(0x0007) -- PIR product @@ -712,9 +596,9 @@ test.register_coroutine_test("Test: 4-Button Device Detection - Profile Changes }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press, and Held Events on 4-Button Device", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -919,9 +803,9 @@ test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press }) test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.held({ state_change = true }))) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 15) and OnOff (Type 256) Device Types with Child Creation", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1015,9 +899,9 @@ test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 1 clusters.OnOff.attributes.OnOff:subscribe(parent, nil) }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-binary and 2-button Based On Endpoint Availability", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1260,9 +1144,9 @@ test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-bin parent_assigned_child_key = "4" }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable Endpoint with Button Support", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1359,9 +1243,9 @@ test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Update to light-level", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1371,9 +1255,9 @@ test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Updat test.wait_for_events() configure_parent(parent) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control Capabilities", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1446,9 +1330,9 @@ test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control clusters.LevelControl.server.commands.MoveToLevelWithOnOff(dimmer, 4, 51, nil, 0, 0) }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: PIR Device - Initialization with Motion and Illuminance Capabilities", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1459,9 +1343,9 @@ test.register_coroutine_test("Test: PIR Device - Initialization with Motion and configure_parent(parent_pir) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: PIR Device - Complete Functionality with Motion, Illuminance, and Dimmer Support", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1551,7 +1435,7 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot }) test.mock_device.add_test_device(child_dimmer) - initiate_info_changed(child_dimmer, "light-level") + initiate_info_changed(child_dimmer, "light-level", parent_pir) test.socket.matter:__expect_send({ parent_pir.id, cluster_base.subscribe(parent_pir, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) @@ -1616,9 +1500,9 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(50))) parent_pir.expect_native_attr_handler_registration(parent_pir, "switchLevel", "level") end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile with Window Covering Child Device", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1677,7 +1561,7 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile }) test.mock_device.add_test_device(child_wc) - initiate_info_changed(child_wc, "window-covering") + initiate_info_changed(child_wc, "window-covering", parent) test.socket.matter:__expect_send({ parent.id, @@ -1757,9 +1641,9 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Window Covering - Preference Changes for Reverse Polarity and Preset Position", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1844,6 +1728,7 @@ test.register_coroutine_test("Test: Window Covering - Preference Changes for Rev test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "false" } })) test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { reverse = "true" } })) + test.socket.device_lifecycle():__queue_receive(parent:generate_info_changed({ })) test.wait_for_events() local reverse_preference_set = child_wc.preferences.reverse assert(reverse_preference_set == "true", "reverse_preference_set is True") @@ -1925,179 +1810,8 @@ test.register_coroutine_test("Test: Window Covering - Preference Changes for Rev cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) }) end, - { - min_api_version = 15 - }) - -test.register_coroutine_test("Test: 1G Window Covering - window-covering Profile and No Duplicate Child Device", function() - test.socket.matter:__set_channel_ordering("relaxed") - add_parent_device(parent_1g) - local wc_1g = create_hager_1g_window_covering("window-covering", parent_1g) - add_matter_device(wc_1g, parent_1g, "window-covering") - test.wait_for_events() - configure_parent(parent_1g) - test.wait_for_events() - - local main_wc_ep = wc_1g:get_field(MAIN_WC_EP) - assert(main_wc_ep == 12, "MAIN_WC_EP is set to 12") - - -- EP12 reported again → no second child created, MAIN_WC_EP already set - test.socket.matter:__queue_receive({ - parent_1g.id, - clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ - data_types.Uint16(12), - })) - }) - test.socket.matter:__expect_send({ - parent_1g.id, - clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 12) - }) - test.wait_for_events() - - test.socket.matter:__queue_receive({ - parent_1g.id, - clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 12, data_types.Array { - { - device_type = data_types.Uint32(514), - revision = data_types.Uint16(1) - } + { + min_api_version = 15 }) - }) - test.wait_for_events() -end, - { - min_api_version = 15 - }) - -test.register_coroutine_test("Test: 1G Relay - Profile Changes Between light-binary and 2-button Based On EP3 Availability", function() - test.socket.matter:__set_channel_ordering("relaxed") - add_parent_device(parent_1g) - local relay_1g = create_hager_1g_relay("light-binary", parent_1g) - add_switch_matter_device(relay_1g, parent_1g) - test.wait_for_events() - configure_parent(parent_1g) - test.wait_for_events() - - -- Scenario 1: EP3 present → light-binary profile - test.socket.matter:__queue_receive({ - parent_1g.id, - clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ - data_types.Uint16(3), - })) - }) - test.socket.matter:__expect_send({ - parent_1g.id, - clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 3) - }) - relay_1g:expect_metadata_update({ profile = "light-binary" }) - test.wait_for_events() - - -- EP3 is device type 256 (OnOff) - no child device created for 1G - test.socket.matter:__queue_receive({ - parent_1g.id, - clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 3, data_types.Array { - { - device_type = data_types.Uint32(256), - revision = data_types.Uint16(1) - } - }) - }) - test.wait_for_events() - - -- Scenario 2: EP3 removed, buttons 8 & 9 present → 2-button profile - test.socket.matter:__queue_receive({ - parent_1g.id, - clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ - data_types.Uint16(8), - data_types.Uint16(9), - })) - }) - test.socket.matter:__expect_send({ - parent_1g.id, - clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 8) - }) - test.socket.matter:__expect_send({ - parent_1g.id, - clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 9) - }) - relay_1g:expect_metadata_update({ profile = "2-button" }) - test.wait_for_events() -end, - { - min_api_version = 15 - }) - -test.register_coroutine_test("Test: Window Covering - Position Attribute Reports With Reverse Polarity", function() - test.socket.matter:__set_channel_ordering("relaxed") - add_parent_device(parent) - local window_covering_device = create_matter_device_with_window("2-button", parent) - add_matter_device(window_covering_device, parent, "2-button") - test.wait_for_events() - configure_parent(parent) - test.wait_for_events() - - local child_wc = announce_window_covering_child(parent) - test.wait_for_events() - - -- Attribute reports are addressed to the host, so reverse is set on the host - test.socket.device_lifecycle():__queue_receive( - parent:generate_info_changed({ preferences = { reverse = "true" } }) - ) - test.wait_for_events() - - -- Verify 50% position via attribute report - direction independent - test.socket.matter:__queue_receive({ - parent.id, - clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 5000) - }) - test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) - test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) - test.wait_for_events() - - -- Verify 100% position via attribute report - with reverse true, this should report closed - test.socket.matter:__queue_receive({ - parent.id, - clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 0) - }) - test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) - test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) - test.wait_for_events() - - -- Verify 0% position via attribute report - with reverse true, this should report open - test.socket.matter:__queue_receive({ - parent.id, - clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 10000) - }) - test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) - test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.open())) - test.wait_for_events() -end, - { - min_api_version = 15 - }) - -test.register_coroutine_test("Test: Window Covering - Unrecognised OperationalStatus Reports Unknown State", function() - test.socket.matter:__set_channel_ordering("relaxed") - add_parent_device(parent) - local window_covering_device = create_matter_device_with_window("2-button", parent) - add_matter_device(window_covering_device, parent, "2-button") - test.wait_for_events() - configure_parent(parent) - test.wait_for_events() - - local child_wc = announce_window_covering_child(parent) - test.wait_for_events() - - -- OperationalStatus 0x03 is neither opening (1) nor closing (2) - test.socket.matter:__queue_receive({ - parent.id, - clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x03) - }) - test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.unknown())) - test.wait_for_events() -end, - { - min_api_version = 15 - }) test.run_registered_tests() From 9d99e756031ca0637648ca6296d41a0268c88743 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Wed, 23 Sep 2026 11:20:01 -0500 Subject: [PATCH 26/31] WWSTCERT-13716 Photoelectric Smoke Alarm (#3229) --- drivers/SmartThings/matter-sensor/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/matter-sensor/fingerprints.yml b/drivers/SmartThings/matter-sensor/fingerprints.yml index 45b21dd386..dc42ad4b07 100644 --- a/drivers/SmartThings/matter-sensor/fingerprints.yml +++ b/drivers/SmartThings/matter-sensor/fingerprints.yml @@ -89,6 +89,11 @@ matterManufacturer: vendorId: 0x120B productId: 0x1060 deviceProfileName: temperature-humidity-battery + - id: "4619/4129" + deviceLabel: Photoelectric Smoke Alarm + vendorId: 0x120B + productId: 0x1021 + deviceProfileName: smoke-battery # Ikea - id: "4476/32773" deviceLabel: TIMMERFLOTTE Temperature/Humidity Sensor From 84d83ed72fa547d8ce373f4b145f02127fe00527 Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Mon, 28 Sep 2026 13:34:57 -0500 Subject: [PATCH 27/31] Matter Camera: Exclude `audioVolume` on unusable volume range (#3242) * CHAD-19242: Exclude audioVolume for unusable range * Keep the capability as-is when ranges are unknown * Update gating for min/max attr subscription, fix log * Guard against divide-by-zero * Track the raw value directly --- .../camera_handlers/attribute_handlers.lua | 32 +- .../camera_handlers/capability_handlers.lua | 12 +- .../camera_utils/device_configuration.lua | 21 +- .../sub_drivers/camera/camera_utils/utils.lua | 22 +- .../src/test/test_matter_camera.lua | 348 ++++++++++++++++++ 5 files changed, 413 insertions(+), 22 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua index 075f9af600..0b08bf27b0 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua @@ -57,10 +57,14 @@ end function CameraAttributeHandlers.volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MIN - -- Convert from [min_volume, max_volume] to [0, 100] before emitting capability + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) or camera_fields.ABS_VOL_MIN local limited_range = max_volume - min_volume + if limited_range <= 0 then + device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(0)) + return + end + -- Convert from [min_volume, max_volume] to [0, 100] before emitting capability local normalized_volume = utils.round((ib.data.value - min_volume) * 100.0 / limited_range) device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(normalized_volume)) end @@ -68,23 +72,33 @@ end function CameraAttributeHandlers.max_volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) local max_volume = ib.data.value - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) - if max_volume > camera_fields.ABS_VOL_MAX or (min_volume and max_volume <= min_volume) then + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) + -- min == max is a valid (non-adjustable) range per the Matter spec, so only max < min is malformed; + -- an out-of-bounds value is clamped, but a malformed relationship is only logged and left as-is, + -- since volume_level_handler already guards against a non-positive range. + if max_volume > camera_fields.ABS_VOL_MAX then device.log.warn(string.format("Device reported invalid maximum (%d) %s volume level range value", ib.data.value, component)) max_volume = camera_fields.ABS_VOL_MAX + elseif min_volume and max_volume < min_volume then + device.log.warn(string.format("Device reported invalid maximum (%d) %s volume level range value", ib.data.value, component)) end - device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component, max_volume) + camera_utils.set_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component, max_volume) + camera_cfg.reconcile_profile_and_capabilities(device) end function CameraAttributeHandlers.min_volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) local min_volume = ib.data.value - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component) - if min_volume < camera_fields.ABS_VOL_MIN or (max_volume and min_volume >= max_volume) then + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) + -- See max_volume_level_handler: min == max is valid, only min > max is malformed and only logged. + if min_volume < camera_fields.ABS_VOL_MIN then device.log.warn(string.format("Device reported invalid minimum (%d) %s volume level range value", ib.data.value, component)) min_volume = camera_fields.ABS_VOL_MIN + elseif max_volume and min_volume > max_volume then + device.log.warn(string.format("Device reported invalid minimum (%d) %s volume level range value", ib.data.value, component)) end - device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component, min_volume) + camera_utils.set_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component, min_volume) + camera_cfg.reconcile_profile_and_capabilities(device) end function CameraAttributeHandlers.status_light_enabled_handler(driver, device, ib, response) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua index ef52efcdbe..ebf0411f99 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua @@ -59,8 +59,8 @@ end function CameraCapabilityHandlers.handle_set_volume(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute local volume_range = max_volume - min_volume local volume = utils.round(cmd.args.volume * volume_range / 100.0 + min_volume) @@ -75,8 +75,8 @@ end function CameraCapabilityHandlers.handle_volume_up(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN local volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) if not volume or volume >= max_volume then return end -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute @@ -91,8 +91,8 @@ end function CameraCapabilityHandlers.handle_volume_down(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN local volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) if not volume or volume <= min_volume then return end -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua index 282d33677f..8a5db7c5b5 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua @@ -41,6 +41,19 @@ local function set_hard_privacy_mode_presence(device, hard_privacy_mode_present) device:set_field(camera_fields.HARD_PRIVACY_MODE_PRESENT, hard_privacy_mode_present == true, { persist = true }) end +--- Leaves audioVolume as-is if the min/max levels aren't both known yet instead of guessing; +--- otherwise requires max > min to include it. +--- @param component string a profile component name +--- @return should_include boolean whether audioVolume belongs on the component argument. +local function should_include_volume_capability(device, component) + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) + if min_volume == nil or max_volume == nil then + return device:supports_capability(capabilities.audioVolume, component) + end + return max_volume > min_volume +end + local function build_webrtc_supported_features() return { bundle = true, @@ -258,7 +271,9 @@ function CameraDeviceConfiguration.match_profile(device) table.insert(main_component_capabilities, capabilities.audioRecording.ID) end table.insert(microphone_component_capabilities, capabilities.audioMute.ID) - table.insert(microphone_component_capabilities, capabilities.audioVolume.ID) + if should_include_volume_capability(device, camera_fields.profile_components.microphone) then + table.insert(microphone_component_capabilities, capabilities.audioVolume.ID) + end end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then table.insert(main_component_capabilities, capabilities.imageCapture.ID) @@ -268,7 +283,9 @@ function CameraDeviceConfiguration.match_profile(device) end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SPEAKER) then table.insert(speaker_component_capabilities, capabilities.audioMute.ID) - table.insert(speaker_component_capabilities, capabilities.audioVolume.ID) + if should_include_volume_capability(device, camera_fields.profile_components.speaker) then + table.insert(speaker_component_capabilities, capabilities.audioVolume.ID) + end end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.IMAGE_CONTROL) then table.insert(main_component_capabilities, capabilities.imageControl.ID) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua index 78792b94b3..5cc7dee498 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua @@ -82,6 +82,14 @@ function CameraUtils.feature_supported(device, cluster_id, feature_flag) return #device:get_endpoints(cluster_id, { feature_bitmap = feature_flag }) > 0 end +function CameraUtils.get_field_for_component(device, field, component) + return device:get_field(string.format("%s_%s", field, component)) +end + +function CameraUtils.set_field_for_component(device, field, component, value, additional_params) + device:set_field(string.format("%s_%s", field, component), value, additional_params) +end + function CameraUtils.update_supported_attributes(device, ib, capability, attribute) local attribute_set = device:get_latest_state( camera_fields.profile_components.main, capability.ID, capability.supportedAttributes.NAME @@ -212,18 +220,22 @@ function CameraUtils.subscribe(device) clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin }, + -- SpeakerMaxLevel/MinLevel and MicrophoneMaxLevel/MinLevel are subscribed alongside audioMute + -- (unconditional on the AUDIO/SPEAKER feature) rather than audioVolume, since audioVolume's own + -- presence in the profile depends on first discovering these values -- gating their subscription + -- on audioVolume already being present would make that discovery impossible. [capabilities.audioMute.ID] = { clusters.CameraAvStreamManagement.attributes.SpeakerMuted, - clusters.CameraAvStreamManagement.attributes.MicrophoneMuted - }, - [capabilities.audioVolume.ID] = { - clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted, clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel }, + [capabilities.audioVolume.ID] = { + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel + }, [capabilities.mode.ID] = { clusters.CameraAvStreamManagement.attributes.StatusLightBrightness }, diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua index e1467edecf..6f5b1251eb 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -420,6 +420,15 @@ local additional_subscribed_attributes = { clusters.Switch.server.events.MultiPressComplete } +-- audioVolume is only added to the speaker/microphone components once a usable (max > min) volume +-- range has been reported; seed a usable range directly so unrelated tests don't have to simulate it. +local function seed_usable_volume_range(device) + device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.speaker, 0) + device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.speaker, 200) + device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.microphone, 0) + device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.microphone, 200) +end + local expected_metadata = { optional_component_capabilities = { { @@ -473,6 +482,7 @@ local expected_metadata = { } local function update_device_profile() + seed_usable_volume_range(mock_device) test.socket.matter:__queue_receive({ mock_device.id, clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { @@ -712,6 +722,7 @@ end test.register_coroutine_test( "Software version change should trigger camera reprofiling when camera endpoint is present", function() + seed_usable_volume_range(mock_device) test.socket.device_lifecycle:__queue_receive( mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } }) ) @@ -965,6 +976,293 @@ test.register_coroutine_test( } ) +local function build_fake_device_with_volume_ranges(raw_levels) + return { + profile = { components = {} }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + }, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER, + cluster_type = "SERVER" + } + } + } + }, + get_field = function(_, field) return raw_levels[field] end, + get_endpoints = function() return {} end, + supports_capability = function() return false end, + try_update_metadata = function(self, metadata) self.updated_metadata = metadata end, + } +end + +local function component_capabilities(updated_metadata, component_name) + for _, component in ipairs(updated_metadata.optional_component_capabilities) do + if component[1] == component_name then + return component[2] + end + end + return {} +end + +local function list_contains(list, value) + for _, v in ipairs(list) do + if v == value then return true end + end + return false +end + +test.register_coroutine_test( + "audioVolume should be excluded from speaker and microphone when the volume range is unusable or unknown", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + -- microphone reports an unusable range (min >= max); speaker hasn't reported a range at all yet + local fake_device = build_fake_device_with_volume_ranges({ + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 100, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 100, + }) + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioMute.ID), "audioMute should still be present on speaker") + assert(not list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from speaker when its range is unknown") + assert(list_contains(microphone_capabilities, capabilities.audioMute.ID), "audioMute should still be present on microphone") + assert(not list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from microphone when min >= max") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "audioVolume should be included on speaker and microphone when the volume range is usable", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local fake_device = build_fake_device_with_volume_ranges({ + [camera_fields.MIN_VOLUME_LEVEL .. "_speaker"] = 0, + [camera_fields.MAX_VOLUME_LEVEL .. "_speaker"] = 200, + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 0, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 200, + }) + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be included on speaker when max > min") + assert(list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be included on microphone when max > min") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "audioVolume should be preserved on a component whose volume range isn't fully known yet", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + -- Speaker already has audioVolume from a prior match; its raw min/max aren't in raw_levels (nil), + -- so it should be carried over as-is rather than being dropped for lack of information. + local existing_components = { + speaker = { capabilities = { audioVolume = { id = capabilities.audioVolume.ID } } } + } + local raw_levels = { + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 100, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 100, -- unusable; forces a real profile change so we can inspect it + } + local fake_device = { + profile = { components = existing_components }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + }, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER, + cluster_type = "SERVER" + } + } + } + }, + get_field = function(_, field) return raw_levels[field] end, + get_endpoints = function() return {} end, + supports_capability = function(_, capability, component) + local comp = existing_components[component] + if not comp then return false end + for _, cap in pairs(comp.capabilities) do + if cap.id == capability.ID then return true end + end + return false + end, + try_update_metadata = function(self, metadata) self.updated_metadata = metadata end, + } + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be preserved on speaker while its range is unknown") + assert(list_contains(microphone_capabilities, capabilities.audioMute.ID), "audioMute should still be present on microphone") + assert(not list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from microphone when min >= max") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Speaker audioVolume should be removed from the profile when its volume range becomes unusable", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, 0) + }) + + local updated_expected_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", + { "switch", "mode" } + }, + { "speaker", + { "audioMute" } -- audioVolume removed: reported max (0) is no longer greater than the known min (0) + }, + { "microphone", + { "audioMute", "audioVolume" } + }, + { "doorbell", + { "button" } + } + }, + profile = "camera" + } + mock_device:expect_metadata_update(updated_expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Speaker/Microphone volume range attributes should stay subscribed even when audioVolume isn't granted yet", + function() + -- Fresh pairing: no volume range has been reported yet, so audioVolume shouldn't be granted on + -- the very first profile match. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + local first_pass_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", { "switch", "mode" } }, + { "speaker", { "audioMute" } }, + { "microphone", { "audioMute" } }, + { "doorbell", { "button" } } + }, + profile = "camera" + } + mock_device:expect_metadata_update(first_pass_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + test.wait_for_events() + + local first_pass_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = first_pass_metadata.optional_component_capabilities} + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = first_pass_profile })) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.supportedFeatures( + {audio="sendrecv", bundle=true, order="audio/video", supportTrickleICE=true, turnSource="player", video="recvonly"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( + {"pan", "panRange", "tilt", "tiltRange", "zoom", "zoomRange", "presets", "maxPresets"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( + {"triggerAugmentation", "perZoneSensitivity"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.localMediaStorage.supportedAttributes( + {"localVideoRecording"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.audioRecording.audioRecording("enabled")) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports", "watermark", "onScreenDisplay"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes( + {"softRecordingPrivacyMode", "softLivestreamPrivacyMode"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedCommands( + {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} + )) + ) + + -- audioVolume isn't active yet, so its current-value attributes shouldn't be (re-)subscribed, but + -- the min/max discovery attributes -- now bundled with audioMute -- must still be. Without that, + -- the driver could never learn the range needed to decide whether audioVolume belongs at all. + for _, attr in ipairs(additional_subscribed_attributes) do + if attr ~= clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel and + attr ~= clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel then + subscribe_request:merge(attr:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Camera privacy mode supportedAttributes/supportedCommands should only expose hardPrivacyMode when PRIV feature is absent", function() @@ -1282,6 +1580,56 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "A volume level report racing a zero-width range update should not divide by zero", + function() + update_device_profile() + test.wait_for_events() + + -- Narrow the range to min == max. This drops audioVolume from the profile, but the internal + -- MAX/MIN_VOLUME_LEVEL fields used for normalization are updated synchronously, before the + -- platform round-trips the narrowed profile back down. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + local updated_expected_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", { "switch", "mode" } }, + { "speaker", { "audioMute" } }, -- audioVolume dropped: the range is now zero-width (100 == 100) + { "microphone", { "audioMute", "audioVolume" } }, + { "doorbell", { "button" } } + }, + profile = "camera" + } + mock_device:expect_metadata_update(updated_expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + test.wait_for_events() + + -- A late SpeakerVolumeLevel report can still arrive before the narrowed profile comes back down. + -- It should report a fixed value instead of dividing by (max - min) == 0. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(0)) + ) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Status Light Enabled reports should generate appropriate events", function() From 6773dc048c9dc2c111221e6897f5f0e575f341d5 Mon Sep 17 00:00:00 2001 From: Konrad K <33450498+KKlimczukS@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:51:04 +0200 Subject: [PATCH 28/31] [WWSTCERT-13880, WWSTCERT-13884, WWSTCERT-13888] adds new fingerprints for Legrand/BTicino switches (#3250) * adds new fingerprint for Legrand Micromodule switch * adds new fingerprint for Legrand/BTicino Light switch * adds new fingerprint for Legrand Connected outlet --- .../SmartThings/zigbee-switch/fingerprints.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index bb2ce5f027..613c471dcb 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -2542,6 +2542,22 @@ zigbeeManufacturer: manufacturer: FIRSTLED model: DC2DC12MiV1 deviceProfileName: light-color-temp-time-restore +#Legrand/BTicino + - id: Legrand/Micromodule switch + deviceLabel: Connected Lighting Micromodule + manufacturer: Legrand + model: Micromodule switch + deviceProfileName: basic-switch + - id: Legrand/Light switch with neutral + deviceLabel: Connected Switch with Neutral + manufacturer: Legrand + model: Light switch with neutral + deviceProfileName: basic-switch + - id: Legrand/Connected outlet + deviceLabel: Connected Socket + manufacturer: Legrand + model: Connected outlet + deviceProfileName: switch-power zigbeeGeneric: - id: "genericSwitch" deviceLabel: Zigbee Switch From 1bc6ae9fc7a80b370cb43c5315853f6eff817a0b Mon Sep 17 00:00:00 2001 From: "Jan Jakubiszyn/SmartThings Development (BJ.jakubiszynS) /SRPOL/Engineer/Samsung Electronics" Date: Wed, 23 Sep 2026 17:41:53 +0200 Subject: [PATCH 29/31] Fix: Reorder delay and increase to 3 seconds --- .../src/sub_drivers/hager/init.lua | 8 +- .../src/test/test_hager_waasys.lua | 376 ++++++++++++++++-- 2 files changed, 343 insertions(+), 41 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua index db8cfe67d4..8df254bde8 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -189,10 +189,10 @@ local function info_changed(driver, device, event, args) end if device.profile.id ~= args.old_st_store.profile.id or device.network_type == device_lib.NETWORK_TYPE_CHILD then - local parent = device:get_parent_device() - local matter_device = get_matter_device(parent) - local map = {} - device.thread:call_with_delay(2, function() + device.thread:call_with_delay(3, function() + local parent = device:get_parent_device() + local matter_device = get_matter_device(parent) + local map = {} if device:supports_capability(capabilities.button) then local button_eps = parent:get_field(BUTTON_EPS) local clean_eps = {} diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua index 4aeee85023..666550dfcf 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -17,6 +17,7 @@ end local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" local PARENT_ID = "PARENT_ID" local BUTTON_EPS = "__button_eps" +local MAIN_WC_EP = "__main_wc_ep" local function create_parent_device(product_id) return test.mock_device.build_test_matter_device({ @@ -434,6 +435,80 @@ local function create_hager_pir_device(profile_name, parent) }) end +-- Create Hager 1G device with ONLY a WindowCovering endpoint (12) - no button or occupancy endpoints +local function create_hager_1g_window_covering(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 1G Window Covering", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.WindowCovering.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.WindowCovering.types.Feature.LIFT | + clusters.WindowCovering.types.Feature.POSITION_AWARE_LIFT | + clusters.WindowCovering.types.Feature.ABSOLUTE_POSITION, + attributes = { + [clusters.WindowCovering.attributes.OperationalStatus.ID] = 0x00, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = 0x0000, + } + } + }, + device_types = { { device_type_id = 0x0202, device_type_revision = 1 } } + }, + } + }) +end + +-- Create Hager 1G Relay device with endpoint 3 (OnOff cluster) +local function create_hager_1g_relay(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 1G Relay", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0005, + }, + parent_device_id = parent.id, + endpoints = { + { + endpoint_id = 0, + clusters = { { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, + device_types = { { device_type_id = 0x0016, device_type_revision = 1 } } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + } + }, + device_types = { { device_type_id = 0x0100, device_type_revision = 1 } } + }, + } + }) +end + local function add_parent_device(parent) test.mock_device.add_test_device(parent) test.socket.device_lifecycle:__queue_receive({ parent.id, "added" }) @@ -496,8 +571,8 @@ end local function initiate_info_changed(device, profile, parent) test.socket.device_lifecycle:__queue_receive(device:generate_info_changed({ profile = { id = profile } })) - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.mock_time.advance_time(2) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.mock_time.advance_time(3) if parent ~= nil then test.socket.device_lifecycle:__queue_receive(parent:generate_info_changed({})) end @@ -515,6 +590,50 @@ local function configure_parent(device) test.mock_time.advance_time(3) end +-- Create window covering child device on EP12 via PartsList and DeviceTypeList reports +local function announce_window_covering_child(host, matter_device) + test.socket.matter:__queue_receive({ + host.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(host, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + for _, ep in ipairs({ 8, 9, 12 }) do + test.socket.matter:__expect_send({ + host.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(host, ep) + }) + end + + test.socket.matter:__queue_receive({ + host.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(host, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + host:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = host.id, + parent_assigned_child_key = "12" + }) + + local child_wc = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("window-covering.yml"), + device_network_id = string.format("%s:12", host.id), + parent_device_id = host.id, + parent_assigned_child_key = "12" + }) + test.mock_device.add_test_device(child_wc) + return child_wc +end + local parent = create_parent_device(0x0006) -- 2G button product local parent_1g = create_parent_device(0x0005) -- 1G button product local parent_pir = create_parent_device(0x0007) -- PIR product @@ -596,9 +715,9 @@ test.register_coroutine_test("Test: 4-Button Device Detection - Profile Changes }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press, and Held Events on 4-Button Device", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -803,9 +922,9 @@ test.register_coroutine_test("Test: Button Event Handling - Pushed, Double Press }) test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.held({ state_change = true }))) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 15) and OnOff (Type 256) Device Types with Child Creation", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -899,9 +1018,9 @@ test.register_coroutine_test("Test: Device Type Handler - Handles Button (Type 1 clusters.OnOff.attributes.OnOff:subscribe(parent, nil) }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-binary and 2-button Based On Endpoint Availability", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1144,9 +1263,9 @@ test.register_coroutine_test("Test: 2G Relay - Profile Changes Between light-bin parent_assigned_child_key = "4" }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable Endpoint with Button Support", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1243,9 +1362,9 @@ test.register_coroutine_test("Test: Dimmer Device - Child Creation for Dimmable }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Update to light-level", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1255,9 +1374,9 @@ test.register_coroutine_test("Test: 1G Dimmer - Initialization and Profile Updat test.wait_for_events() configure_parent(parent) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control Capabilities", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1330,9 +1449,9 @@ test.register_coroutine_test("Test: 1G Dimmer - Host Commands and Level Control clusters.LevelControl.server.commands.MoveToLevelWithOnOff(dimmer, 4, 51, nil, 0, 0) }) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: PIR Device - Initialization with Motion and Illuminance Capabilities", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1343,9 +1462,9 @@ test.register_coroutine_test("Test: PIR Device - Initialization with Motion and configure_parent(parent_pir) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: PIR Device - Complete Functionality with Motion, Illuminance, and Dimmer Support", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1417,6 +1536,7 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot }) test.wait_for_events() initiate_info_changed(pir_device, "motion-illuminance") + test.socket.matter:__expect_send({ parent_pir.id, cluster_base.subscribe(parent_pir, nil, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) @@ -1433,8 +1553,8 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot parent_device_id = parent_pir.id, parent_assigned_child_key = "3" }) - test.mock_device.add_test_device(child_dimmer) + initiate_info_changed(child_dimmer, "light-level", parent_pir) test.socket.matter:__expect_send({ parent_pir.id, @@ -1453,6 +1573,7 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot cluster_base.subscribe(parent_pir, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) }) test.wait_for_events() + --Verify motion detected event test.socket.matter:__queue_receive({ parent_pir.id, @@ -1476,13 +1597,14 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot parent_pir.id, clusters.OnOff.commands.On(parent_pir, 3) }) + -- Verify on state via attribute report test.socket.matter:__queue_receive({ parent_pir.id, clusters.OnOff.attributes.OnOff:build_test_report_data(parent_pir, 3, true) }) - test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.on())) parent_pir.expect_native_attr_handler_registration(parent_pir, "switch", "switch") + test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switch.switch.on())) -- Set dimmer level to 50% test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } } }) @@ -1500,9 +1622,9 @@ test.register_coroutine_test("Test: PIR Device - Complete Functionality with Mot test.socket.capability:__expect_send(child_dimmer:generate_test_message("main", capabilities.switchLevel.level(50))) parent_pir.expect_native_attr_handler_registration(parent_pir, "switchLevel", "level") end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile with Window Covering Child Device", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1561,7 +1683,7 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile }) test.mock_device.add_test_device(child_wc) - initiate_info_changed(child_wc, "window-covering", parent) + initiate_info_changed(child_wc, "window-covering",parent) test.socket.matter:__expect_send({ parent.id, @@ -1641,9 +1763,9 @@ test.register_coroutine_test("Test: Host with Window Covering - 2-Button Profile test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) end, - { - min_api_version = 15 - }) + { + min_api_version = 15 + }) test.register_coroutine_test("Test: Window Covering - Preference Changes for Reverse Polarity and Preset Position", function() test.socket.matter:__set_channel_ordering("relaxed") @@ -1810,8 +1932,188 @@ test.register_coroutine_test("Test: Window Covering - Preference Changes for Rev cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) }) end, - { - min_api_version = 15 + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: 1G Window Covering - window-covering Profile and No Duplicate Child Device", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_1g) + local wc_1g = create_hager_1g_window_covering("window-covering", parent_1g) + add_matter_device(wc_1g, parent_1g, "window-covering") + test.wait_for_events() + configure_parent(parent_1g) + test.wait_for_events() + + local main_wc_ep = wc_1g:get_field(MAIN_WC_EP) + assert(main_wc_ep == 12, "MAIN_WC_EP is set to 12") + + -- EP12 reported again → no second child created, MAIN_WC_EP already set + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(12), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 12) + }) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: 1G Relay - Profile Changes Between light-binary and 2-button Based On EP3 Availability", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent_1g) + local relay_1g = create_hager_1g_relay("light-binary", parent_1g) + add_switch_matter_device(relay_1g, parent_1g) + test.wait_for_events() + configure_parent(parent_1g) + test.wait_for_events() + + -- Scenario 1: EP3 present → light-binary profile + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(3), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 3) + }) + relay_1g:expect_metadata_update({ profile = "light-binary" }) + test.wait_for_events() + + -- EP3 is device type 256 (OnOff) - no child device created for 1G + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 3, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } }) + }) + test.wait_for_events() + + -- Scenario 2: EP3 removed, buttons 8 & 9 present → 2-button profile + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_1g, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 8) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 9) + }) + relay_1g:expect_metadata_update({ profile = "2-button" }) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Window Covering - Position Attribute Reports With Reverse Polarity", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + local child_wc = announce_window_covering_child(parent) + test.wait_for_events() + + -- Attribute reports are addressed to the host, so reverse is set on the host + test.socket.device_lifecycle():__queue_receive( + parent:generate_info_changed({ preferences = { reverse = "true" } }) + ) + test.wait_for_events() + + -- Verify 50% position via attribute report - direction independent + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 5000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) + test.wait_for_events() + + -- Verify 100% position via attribute report - with reverse true, this should report closed + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 0) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closed())) + test.wait_for_events() + + -- Verify 0% position via attribute report - with reverse true, this should report open + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data(parent, 12, 10000) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.open())) + test.wait_for_events() +end, + { + min_api_version = 15 + }) + +test.register_coroutine_test("Test: Window Covering - Unrecognised OperationalStatus Reports Unknown State", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local window_covering_device = create_matter_device_with_window("2-button", parent) + add_matter_device(window_covering_device, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + local child_wc = announce_window_covering_child(parent) + initiate_info_changed(child_wc, "window-covering", parent) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + }) + test.wait_for_events() + + -- OperationalStatus 0x03 is neither opening (1) nor closing (2) + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x03) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.unknown())) + test.wait_for_events() +end, + { + min_api_version = 15 + }) test.run_registered_tests() From 7ae09375170b5290ce1e194b81a57e2ef5c6ef1e Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Fri, 2 Oct 2026 10:01:04 -0500 Subject: [PATCH 30/31] WWSTCERT-13927 Linkind Smart Outdoor Plug (#3256) --- drivers/SmartThings/matter-switch/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index fd2a35c321..6dcbffe532 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -241,6 +241,11 @@ matterManufacturer: vendorId: 0x1396 productId: 0x11DE deviceProfileName: light-color-level + - id: "5014/4713" + deviceLabel: Linkind Smart Outdoor Plug + vendorId: 0x1396 + productId: 0x1269 + deviceProfileName: plug-binary #Bosch Smart Home - id: "4617/12310" deviceLabel: Plug Compact [M] From 43f47501b217336d17ed2fc92b64d08971f305ce Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Mon, 5 Oct 2026 10:45:20 -0500 Subject: [PATCH 31/31] WWSTCERT-13921 Linkind Smart Light Bulb (#3258) --- .../matter-switch/fingerprints.yml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 6dcbffe532..a889663b92 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -241,6 +241,36 @@ matterManufacturer: vendorId: 0x1396 productId: 0x11DE deviceProfileName: light-color-level + - id: "5014/4224" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1080 + deviceProfileName: light-color-level + - id: "5014/4223" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x107F + deviceProfileName: light-color-level + - id: "5014/4227" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1083 + deviceProfileName: light-color-level + - id: "5014/4218" + deviceLabel: Linkind Smart Filament Bulb + vendorId: 0x1396 + productId: 0x107A + deviceProfileName: light-color-level + - id: "5014/4220" + deviceLabel: Linkind Smart Filament Bulb + vendorId: 0x1396 + productId: 0x107C + deviceProfileName: light-color-level + - id: "5014/4221" + deviceLabel: Linkind Smart Filament Bulb + vendorId: 0x1396 + productId: 0x107D + deviceProfileName: light-color-level - id: "5014/4713" deviceLabel: Linkind Smart Outdoor Plug vendorId: 0x1396