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 29c85f6c79..4590ba0a74 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,56 @@ 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 + 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) @@ -1639,6 +1689,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 @@ -2325,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/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() 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 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..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 @@ -965,7 +971,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