diff --git a/.github/scripts/check_duplicates.py b/.github/scripts/check_duplicates.py index 65e2ae2ea6..e335f6083a 100644 --- a/.github/scripts/check_duplicates.py +++ b/.github/scripts/check_duplicates.py @@ -1,4 +1,3 @@ -import csv import os import yaml from pathlib import Path @@ -101,51 +100,49 @@ def compare_components(prof1, prof2): return True -with open(str(Path.home()) + '/files.csv', 'r') as csvfile: - csvreader = csv.reader(csvfile) - changed_files = next(csvreader) - - for file in changed_files: - file_basename = os.path.basename(file) - file_directory = os.path.dirname(file) - - if '/profiles/' in file: - print('\nNEW PROFILE:\n%s is a profile! Comparing to other profiles...' % file) - - os.chdir(file_directory) - new_profile = file_basename - - # Skip deleted files and track them for warning - if not os.path.exists(new_profile): - print("Skipping %s - file was deleted" % new_profile) - deleted_profiles.append(file) - os.chdir(cwd) - continue - - for current_profile in os.listdir("./"): - # compare to YAML files that are not the same file - # Compare only .yml files and only files that have not already been found to be a duplicate - if current_profile != new_profile and Path(current_profile).suffix == ".yml" and (current_profile, new_profile) not in duplicate_pairs: - print("Comparing %s vs %s" % (new_profile, current_profile)) - with open(new_profile) as new_data, open(current_profile) as current_data: - new_profile_map = yaml.safe_load(new_data) - current_profile_map = yaml.safe_load(current_data) - - ''' Compare profiles. A duplicate is defined as follows: - - categories must be the same - - capabilities must be the same, with some ordering restrictions - - top capability must match, but subsequent ordering does not matter - - embedded configs must be the same, but certain values can be ordered differently (i.e. enabledValues) - - preferences must be the same - ''' - if(compare_preferences(new_profile_map, current_profile_map) == True and - compare_metadata(new_profile_map, current_profile_map) == True and - compare_components(new_profile_map, current_profile_map) == True): - print("%s and %s are duplicates!\n" % (new_profile, current_profile)) - duplicate_pairs.append((new_profile, current_profile)) - - # return to original directory - os.chdir(cwd) +changed_files = os.environ.get('ALL_CHANGED_FILES', '').split() + +for file in changed_files: + file_basename = os.path.basename(file) + file_directory = os.path.dirname(file) + + if '/profiles/' in file: + print('\nNEW PROFILE:\n%s is a profile! Comparing to other profiles...' % file) + + os.chdir(file_directory) + new_profile = file_basename + + # Skip deleted files and track them for warning + if not os.path.exists(new_profile): + print("Skipping %s - file was deleted" % new_profile) + deleted_profiles.append(file) + os.chdir(cwd) + continue + + for current_profile in os.listdir("./"): + # compare to YAML files that are not the same file + # Compare only .yml files and only files that have not already been found to be a duplicate + if current_profile != new_profile and Path(current_profile).suffix == ".yml" and (current_profile, new_profile) not in duplicate_pairs: + print("Comparing %s vs %s" % (new_profile, current_profile)) + with open(new_profile) as new_data, open(current_profile) as current_data: + new_profile_map = yaml.safe_load(new_data) + current_profile_map = yaml.safe_load(current_data) + + ''' Compare profiles. A duplicate is defined as follows: + - categories must be the same + - capabilities must be the same, with some ordering restrictions + - top capability must match, but subsequent ordering does not matter + - embedded configs must be the same, but certain values can be ordered differently (i.e. enabledValues) + - preferences must be the same + ''' + if(compare_preferences(new_profile_map, current_profile_map) == True and + compare_metadata(new_profile_map, current_profile_map) == True and + compare_components(new_profile_map, current_profile_map) == True): + print("%s and %s are duplicates!\n" % (new_profile, current_profile)) + duplicate_pairs.append((new_profile, current_profile)) + + # return to original directory + os.chdir(cwd) with open("profile-comment-body.md", "w") as f: if duplicate_pairs: diff --git a/.github/scripts/check_profile_categories.py b/.github/scripts/check_profile_categories.py new file mode 100644 index 0000000000..31609cbe7e --- /dev/null +++ b/.github/scripts/check_profile_categories.py @@ -0,0 +1,82 @@ +import os +import sys +import yaml +from pathlib import Path + +cwd = os.getcwd() +missing_category_profiles = [] +deleted_profiles = [] + +changed_files = os.environ.get('ALL_CHANGED_FILES', '').split() + +for file in changed_files: + file_basename = os.path.basename(file) + file_directory = os.path.dirname(file) + + if '/profiles/' in file and file.endswith('.yml'): + print('\nCHECKING PROFILE:\n%s' % file) + + os.chdir(file_directory) + + if not os.path.exists(file_basename): + print("Skipping %s - file was deleted" % file_basename) + deleted_profiles.append(file) + os.chdir(cwd) + continue + + with open(file_basename) as fp: + try: + profile = yaml.safe_load(fp) + except yaml.YAMLError as e: + print("Error parsing %s: %s" % (file_basename, e)) + os.chdir(cwd) + continue + + if not profile or 'components' not in profile: + print("Skipping %s - no components found" % file_basename) + os.chdir(cwd) + continue + + # Find the main component and verify it has a categories field + main_component = next( + (c for c in profile['components'] if c.get('id') == 'main'), + None + ) + + if main_component is None: + print("Warning: %s has no 'main' component" % file_basename) + os.chdir(cwd) + continue + + if not main_component.get('categories'): + print("MISSING CATEGORY: %s" % file) + missing_category_profiles.append(file) + else: + print("OK: %s has categories: %s" % ( + file_basename, + [c['name'] for c in main_component['categories']] + )) + + os.chdir(cwd) + +with open("profile-categories-comment-body.md", "w") as f: + if missing_category_profiles: + f.write("Profile category check: :x: **Missing categories detected.**\n\n") + f.write("The following profiles are missing a `categories` field on the `main` component:\n\n") + for profile in missing_category_profiles: + f.write("- `%s`\n" % profile) + f.write("\nPlease add a `categories` entry to the `main` component. Example:\n") + f.write("```yaml\ncomponents:\n - id: main\n categories:\n - name: Switch\n capabilities:\n ...\n```\n") + else: + f.write("Profile category check: :white_check_mark: Passed - all profiles have a category defined.\n") + + if deleted_profiles: + f.write("\n:warning: **Deleted profile files detected:**\n") + for deleted in deleted_profiles: + f.write("- `%s`\n" % deleted) + +with open("profile-categories-comment-body.md", "r") as f: + print("\n" + f.read()) + +if missing_category_profiles: + sys.exit(1) diff --git a/.github/workflows/check-profile-categories-comment.yml b/.github/workflows/check-profile-categories-comment.yml new file mode 100644 index 0000000000..cf207144ef --- /dev/null +++ b/.github/workflows/check-profile-categories-comment.yml @@ -0,0 +1,34 @@ +name: Post profile categories comment +on: + workflow_run: + workflows: [Check profile categories] + types: + - completed + +jobs: + comment-on-pr: + runs-on: ubuntu-latest + steps: + - name: Download comment artifact + uses: dawidd6/action-download-artifact@v6 + with: + workflow: check-profile-categories.yml + run_id: ${{ github.event.workflow_run.id }} + + - run: echo "pr_number=$(cat pr_number/pr_number.txt)" >> $GITHUB_ENV + + - name: Find comment + uses: peter-evans/find-comment@v2 + id: fc + with: + body-includes: Profile category check + comment-author: 'github-actions[bot]' + issue-number: ${{ env.pr_number }} + + - name: Post comment + uses: peter-evans/create-or-update-comment@v2 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + body-file: 'profile_categories_comment/profile-categories-comment-body.md' + edit-mode: replace + issue-number: ${{ env.pr_number }} diff --git a/.github/workflows/check-profile-categories.yml b/.github/workflows/check-profile-categories.yml new file mode 100644 index 0000000000..6340175810 --- /dev/null +++ b/.github/workflows/check-profile-categories.yml @@ -0,0 +1,41 @@ +name: Check profile categories +on: + pull_request: + types: [opened, synchronize] + paths: + - 'drivers/**/profiles/*.yml' + +jobs: + check-profile-categories: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Gather file changes + id: changed-files + uses: tj-actions/changed-files@v47 + + - name: Run python script + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + run: | + python ./.github/scripts/check_profile_categories.py + + - name: Upload profile categories comment artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: profile_categories_comment + path: | + profile-categories-comment-body.md + + - run: echo ${{ github.event.number }} > pr_number.txt + + - name: Upload PR info + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr_number + path: | + pr_number.txt diff --git a/.github/workflows/duplicate-profiles.yml b/.github/workflows/duplicate-profiles.yml index ae524cc1c2..5f66269cc1 100644 --- a/.github/workflows/duplicate-profiles.yml +++ b/.github/workflows/duplicate-profiles.yml @@ -13,20 +13,13 @@ jobs: - name: Checkout uses: actions/checkout@v3 - # Creates file "$/files.csv", among others - - id: file_changes - name: Gather file changes - uses: trilom/file-changes-action@1.2.4 - with: - output: ',' - fileOutput: ',' - - # For verification - - name: Show files changed - run: | - cat $HOME/files.csv + - name: Gather file changes + id: changed-files + uses: tj-actions/changed-files@v47 - name: Run python script + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} run: | python ./.github/scripts/check_duplicates.py diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml index 57c8812913..099ac90648 100644 --- a/.github/workflows/publish-test-results.yml +++ b/.github/workflows/publish-test-results.yml @@ -22,9 +22,66 @@ jobs: event_file: event-file/event.json event_name: ${{ github.event.workflow_run.event }} files: "tests/*.xml" - - name: Publish coverage results + + # 5monkeys/cobertura-action has a bug where the `files` array that builds + # each per-XML markdown table is declared once outside the loop over reports + # and is never reset. When multiple XML files are passed via a glob, every + # subsequent driver's table accumulates all files from earlier drivers. + # + # Fix: determine which XML files exist at runtime, then fan out into a matrix + # job so each driver's XML is processed by its own isolated action invocation. + list-coverage-files: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.build-matrix.outputs.matrix }} + steps: + - name: Download coverage artifact + continue-on-error: true + uses: dawidd6/action-download-artifact@v6 + with: + workflow: run-tests.yml + run_id: ${{ github.event.workflow_run.id }} + name: coverage + path: coverage + - name: Build matrix of coverage files + id: build-matrix + run: | + files=$(find coverage -maxdepth 1 -name '*_coverage.xml' -printf '%f\n' 2>/dev/null | sort) + if [ -z "$files" ]; then + echo 'matrix=[]' >> $GITHUB_OUTPUT + else + json=$(echo "$files" | python3 -c 'import sys,json; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))') + echo "matrix=$json" >> $GITHUB_OUTPUT + fi + + publish-coverage: + needs: list-coverage-files + if: needs.list-coverage-files.outputs.matrix != '[]' && needs.list-coverage-files.outputs.matrix != '' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + coverage_file: ${{ fromJson(needs.list-coverage-files.outputs.matrix) }} + steps: + - name: Download coverage artifact + uses: dawidd6/action-download-artifact@v6 + with: + workflow: run-tests.yml + run_id: ${{ github.event.workflow_run.id }} + name: coverage + path: coverage + - name: Download pr_number artifact + uses: dawidd6/action-download-artifact@v6 + with: + workflow: run-tests.yml + run_id: ${{ github.event.workflow_run.id }} + name: pr_number + path: pr_number + - run: echo "pr_number=$(cat pr_number/pr_number.txt)" >> $GITHUB_ENV + - name: Publish coverage for ${{ matrix.coverage_file }} uses: 5monkeys/cobertura-action@master with: pull_request_number: ${{ env.pr_number }} - path: "coverage/*.xml" + path: coverage/${{ matrix.coverage_file }} minimum_coverage: 90 + report_name: ${{ matrix.coverage_file }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f6a03154cb..de88fc02d1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,6 +4,8 @@ on: types: [opened, synchronize, labeled, unlabeled] paths: - 'drivers/**' + - 'tools/run_driver_tests.py' + - 'tools/run_driver_tests_p.py' jobs: # Two separate jobs for finding the right artifact to run tests with @@ -91,11 +93,33 @@ jobs: - run: echo ${{ steps.changed-drivers.outputs.all_modified_files }} - name: Install Python requirements run: pip install -r tools/requirements.txt + - name: Fetch capability definitions + continue-on-error: true + run: | + python3 tools/fetch_capability_definitions.py \ + --drivers-dir ${{ github.workspace }}/drivers \ + --output-dir ${{ github.workspace }}/capability_json \ + --failed-output-file ${{ github.workspace }}/capability_failures_comment.md + env: + CAPABILITY_PAT: ${{ secrets.CAPABILITY_DEFINITIONS_PAT }} + - name: Report capability download failures + if: hashFiles('capability_failures_comment.md') != '' && always() + uses: marocchino/sticky-pull-request-comment@v2 + with: + path: ${{ github.workspace }}/capability_failures_comment.md + header: capability-download-failures + - name: Clear capability download failures comment + if: hashFiles('capability_failures_comment.md') == '' && always() + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: capability-download-failures + delete: true - name: Run the tests id: run-tests run: python tools/run_driver_tests_p.py ${{ steps.changed-drivers.outputs.all_modified_files }} env: LUA_PATH: ${{ steps.lua_path.outputs.lua_path }} + ST_CAPABILITY_JSON_DIR: ${{ github.workspace }}/capability_json - name: Upload test artifact if: always() uses: actions/upload-artifact@v4 diff --git a/Jenkinsfile b/Jenkinsfile index 425da986db..752d3d358c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -24,11 +24,46 @@ def getChangedDrivers() { return drivers } +def get_region() { + def url = env.JENKINS_URL?.trim() + if (url?.endsWith('/')) { + url = url[0..-2] + } + + def region = url?.endsWith('.cn') ? 'cn' : 'global' + return region +} + +// Gate artifactory-credentials for prod nodes in cn due to a docker authentication error arising in that environment +def getDockerCredentialId() { + def nodeLabel = params.NODE_LABEL ?: 'production' + def region = get_region() + if (nodeLabel == 'production' && region == 'cn') { + return 'artifactory-credentials' + } + else { + return '' + } +} +// Gate RegistryUrl for prod nodes in cn due to a docker authentication error arising in that environment +def getRegistryUrl() { + def nodeLabel = params.NODE_LABEL ?: 'production' + def region = get_region() + if (nodeLabel == 'production' && region == 'cn') { + return 'https://registry.artifactoryedge.streleng.cn' + } else { + // Default + return '' + } +} + pipeline { agent { docker { image 'python:3.10' label "${params.NODE_LABEL ?: 'production'}" + registryUrl getRegistryUrl() + registryCredentialsId getDockerCredentialId() args '--entrypoint= -u 0:0' } } diff --git a/drivers/SmartThings/matter-lock/fingerprints.yml b/drivers/SmartThings/matter-lock/fingerprints.yml index 7eb674bce9..b81ae4c48f 100755 --- a/drivers/SmartThings/matter-lock/fingerprints.yml +++ b/drivers/SmartThings/matter-lock/fingerprints.yml @@ -197,12 +197,23 @@ matterManufacturer: vendorId: 0x152C productId: 0x9501 deviceProfileName: lock + #Schlage + - id: "4662/14336" + deviceLabel: Schlage Sense Pro + vendorId: 0x1236 + productId: 0x3800 + deviceProfileName: lock-modular #Lockly - id: "5212/2" deviceLabel: Lockly Smart Lock vendorId: 0x145C productId: 0x0002 deviceProfileName: lock-modular + - id: "5212/3" + deviceLabel: Lockly Smart Lock + vendorId: 0x145C + productId: 0x0003 + deviceProfileName: lock-modular matterGeneric: - id: "matter/door-lock" deviceLabel: Matter Door Lock diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua index 2e749ebacd..334ad78331 100644 --- a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua @@ -2,7 +2,7 @@ local data_types = require "st.matter.data_types" local UintABC = require "st.matter.data_types.base_defs.UintABC" local MeasurementTypeEnum = {} -local new_mt = UintABC.new_mt({NAME = "MeasurementTypeEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +local new_mt = UintABC.new_mt({NAME = "MeasurementTypeEnum", ID = data_types.name_to_id_map["Uint16"]}, 2) new_mt.__index.pretty_print = function(self) local name_lookup = { [self.UNSPECIFIED] = "UNSPECIFIED", diff --git a/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua index eaa8730fc4..1867d65e0b 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua @@ -13,6 +13,12 @@ if version.api < 13 then clusters.Global = require "embedded_clusters.Global" end +-- The SOIL_MOISTURE MeasurementTypeEnum variant was added to the Global MeasurementTypeEnum +-- def in lua libs in api version 21 as a part of the fix for Shared and Global types. +if version.api < 21 then + clusters.Global.types.MeasurementTypeEnum = require "embedded_clusters.Global.types.MeasurementTypeEnum" +end + local AttributeHandlers = {} @@ -84,8 +90,10 @@ function AttributeHandlers.soil_moisture_measured_value_handler(driver, device, end function AttributeHandlers.soil_moisture_measurement_limits_handler(driver, device, ib, response) - local MeasurementAccuracyStruct = require "embedded_clusters.Global.types.MeasurementAccuracyStruct" - MeasurementAccuracyStruct:augment_type(ib.data) + if version.api < 13 then + local MeasurementAccuracyStruct = require "embedded_clusters.Global.types.MeasurementAccuracyStruct" + MeasurementAccuracyStruct:augment_type(ib.data) + end local min_val = ib.data.elements and ib.data.elements.min_measured_value and ib.data.elements.min_measured_value.value local max_val = ib.data.elements and ib.data.elements.max_measured_value and ib.data.elements.max_measured_value.value if not (min_val and max_val) or (min_val >= max_val) or (min_val < sensor_utils.SOIL_MOISTURE_MIN) or (max_val > sensor_utils.SOIL_MOISTURE_MAX) then @@ -229,4 +237,4 @@ function AttributeHandlers.flow_measured_value_bounds_factory(minOrMax) end end -return AttributeHandlers \ No newline at end of file +return AttributeHandlers diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua index 0c845fa47e..a5a8042fc0 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua @@ -7,10 +7,15 @@ local t_utils = require "integration_test.utils" local test = require "integration_test" local version = require "version" -clusters.Global = require "embedded_clusters.Global" +if version.api < 13 then + clusters.Global = require "embedded_clusters.Global" +end if version.api < 21 then clusters.SoilMeasurement = require "embedded_clusters.SoilMeasurement" + -- The SOIL_MOISTURE MeasurementTypeEnum variant was added to the Global MeasurementTypeEnum + -- def in lua libs in api version 21. + clusters.Global.types.MeasurementTypeEnum = require "embedded_clusters.Global.types.MeasurementTypeEnum" end local mock_device = test.mock_device.build_test_matter_device({ @@ -157,6 +162,9 @@ test.register_coroutine_test( ) local function build_soil_moisture_limits(min_value, max_value) + if version.api < 21 then + clusters.Global.types.MeasurementTypeEnum = require "embedded_clusters.Global.types.MeasurementTypeEnum" + end return clusters.Global.types.MeasurementAccuracyStruct({ measurement_type = clusters.Global.types.MeasurementTypeEnum.SOIL_MOISTURE, measured = true, diff --git a/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua b/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua index 50c7512838..2b02b5ec73 100644 --- a/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua +++ b/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua @@ -7,7 +7,6 @@ local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local custom_capabilities = require "shus-mattress/custom_capabilities" local shus_mattress_profile_def = t_utils.get_profile_definition("shus-smart-mattress.yml") test.add_package_capability("aiMode.yaml") @@ -18,6 +17,8 @@ test.add_package_capability("strongExpMode.yaml") test.add_package_capability("yoga.yaml") test.add_package_capability("mattressHardness.yaml") +local custom_capabilities = require "shus-mattress/custom_capabilities" + local PRIVATE_CLUSTER_ID = 0xFCC2 local MFG_CODE = 0x1235 diff --git a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml index c1f859037b..6fce8d0d3a 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml +++ b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml @@ -59,3 +59,8 @@ zigbeeManufacturer: manufacturer: MultIR model: MIR-SM200 deviceProfileName: smoke-battery-tamper-no-fw-update + - id: ShinaSystem/FAM-300Z + deviceLabel: SiHAS Smoke Detector + manufacturer: ShinaSystem + model: FAM-300Z + deviceProfileName: smoke-battery diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/can_handle.lua new file mode 100644 index 0000000000..c13aebce6a --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function shinasystem_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "ShinaSystem" and (device:get_model() == "FAM-300Z") then + return true, require("shinasystem") + end + return false +end + +return shinasystem_can_handle diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/init.lua new file mode 100644 index 0000000000..19e59e4aaf --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/shinasystem/init.lua @@ -0,0 +1,34 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local IASZone = zcl_clusters.IASZone + +local CONFIGURATIONS = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 1, + maximum_interval = 1200, -- Zigbee poll interval is 600s. Added because the default reporting maximum_interval (180s) must be greater than 600s. + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1 + } +} + +local function device_init(driver, device) + if CONFIGURATIONS ~= nil then + for _, attribute in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(attribute) + end + end +end + +local shinasystem_smoke_sensor = { + NAME = "shinasystem smoke sensor", + lifecycle_handlers = { + init = device_init + }, + can_handle = require("shinasystem.can_handle"), +} +return shinasystem_smoke_sensor diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua b/drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua index 2b917b85dc..2f7a4b29ff 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua @@ -7,5 +7,6 @@ local sub_drivers = { lazy_load_if_possible("aqara-gas"), lazy_load_if_possible("aqara"), lazy_load_if_possible("MultiIR"), + lazy_load_if_possible("shinasystem"), } return sub_drivers diff --git a/tools/config.luacov b/tools/config.luacov index b3edf6673b..26034bfb27 100644 --- a/tools/config.luacov +++ b/tools/config.luacov @@ -3,6 +3,8 @@ configuration = { "scripting-engine", "lua_libs", "test", + "embedded_clusters", + "embedded_cluster_utils", "unofficial", "DishwasherAlarm", "DishwasherMode", diff --git a/tools/fetch_capability_definitions.py b/tools/fetch_capability_definitions.py new file mode 100644 index 0000000000..45ef47b9cd --- /dev/null +++ b/tools/fetch_capability_definitions.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +# Copyright 2026 SmartThings +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""fetch_capability_definitions.py + +Scans driver profile YAML files to discover all capability IDs and versions in +use, then downloads their fully-inlined JSON definitions from the SmartThings +cloud API and writes each to a separate file in the output directory. + +The downloaded JSON files are pre-inlined by the cloud (no $ref strings remain) +and can be used directly by the integration test framework's capability_json_loader +module via the ST_CAPABILITY_JSON_DIR environment variable. + +Capability definitions are fetched individually via GET /capabilities// +by default, which ensures that a failure for one capability does not affect others. +A known allowlist of capabilities (QUERY_ENDPOINT_IDS) that are confirmed to work +with the bulk POST /capabilities/query endpoint are fetched in a single batch +request instead, for efficiency. + +Usage +----- + python3 tools/fetch_capability_definitions.py \\ + --drivers-dir drivers/ \\ + --drivers-dir ~/projects/SmartThingsEdgeDrivers/drivers \\ + --output-dir ~/cap_cache \\ + [--overwrite] \\ + [--failed-output-file failures_comment.md] + +Arguments +--------- + --drivers-dir PATH Root of a driver tree to scan for profiles. + May be specified multiple times. + --output-dir PATH Directory to write _.json files. + Created if it does not exist. + --overwrite When set, re-download and overwrite all capability + files even if they already exist in --output-dir. + When absent (default), only missing files are fetched; + capabilities already on disk are skipped entirely, + and no API request is made for them. + --failed-output-file PATH When set, write a markdown-formatted report of any + capabilities that could not be downloaded to this file. + The file is only created when there are failures, making + it suitable for use as a PR comment via hashFiles() checks + in CI workflows. + +Environment +----------- + CAPABILITY_PAT Required. SmartThings personal access token used for + the Authorization: Bearer header on all API requests. +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Dict, List, Set, Tuple + +import requests +import yaml + +API_BASE = "https://api.smartthings.com/v1" +QUERY_ENDPOINT = f"{API_BASE}/capabilities/query" + +# These two IDs appear in the /capabilities list but are rejected by both the +# /capabilities/query endpoint and the individual GET endpoint. They are +# lowercase/deprecated duplicates of alarmSensor and samsungTV respectively. +EXCLUDED_IDS = frozenset(["alarmsensor", "samsungTv"]) + +# These capability IDs are confirmed to work with the bulk POST /capabilities/query +# endpoint and will be fetched in a single batch request for efficiency. All other +# capability IDs (e.g. namespaced capabilities such as stse.*, sec.*, samsungim.*) +# are fetched individually via GET /capabilities// by default. This +# ensures that a new capability added to a profile that is not supported by the +# query endpoint will only cause an isolated failure rather than affecting all +# capabilities in the batch. +QUERY_ENDPOINT_IDS = frozenset([ + "accelerationSensor", + "activitySensor", + "airConditionerFanMode", + "airConditionerMode", + "airPurifierFanMode", + "airQualityHealthConcern", + "airQualitySensor", + "alarm", + "atmosphericPressureMeasurement", + "audioMute", + "audioNotification", + "audioRecording", + "audioTrackData", + "audioVolume", + "battery", + "batteryLevel", + "bodyWeightMeasurement", + "bridge", + "button", + "cameraPrivacyMode", + "cameraViewportSettings", + "carbonDioxideHealthConcern", + "carbonDioxideMeasurement", + "carbonMonoxideDetector", + "carbonMonoxideHealthConcern", + "carbonMonoxideMeasurement", + "chargingState", + "chime", + "colorControl", + "colorTemperature", + "configuration", + "contactSensor", + "cookTime", + "currentMeasurement", + "dewPoint", + "doorControl", + "dustHealthConcern", + "dustSensor", + "elevatorCall", + "energyMeter", + "evseChargingSession", + "evseState", + "fanMode", + "fanOscillationMode", + "fanSpeed", + "fanSpeedPercent", + "feederOperatingState", + "feederPortion", + "filterState", + "filterStatus", + "fineDustHealthConcern", + "fineDustSensor", + "firmwareUpdate", + "flowMeasurement", + "formaldehydeHealthConcern", + "formaldehydeMeasurement", + "gasDetector", + "gasMeter", + "hardwareFault", + "hdr", + "illuminanceMeasurement", + "imageCapture", + "imageControl", + "keypadInput", + "knob", + "laundryWasherRinseMode", + "laundryWasherSpinSpeed", + "level", + "localMediaStorage", + "lock", + "lockAlarm", + "lockAliro", + "lockCodes", + "lockCredentials", + "lockSchedules", + "lockUsers", + "mechanicalPanTiltZoom", + "mediaGroup", + "mediaPlayback", + "mediaPresets", + "mediaTrackControl", + "mode", + "moldHealthConcern", + "momentary", + "motionSensor", + "movementSensor", + "multipleZonePresence", + "nightVision", + "nitrogenDioxideHealthConcern", + "nitrogenDioxideMeasurement", + "operationalState", + "ozoneHealthConcern", + "ozoneMeasurement", + "panicAlarm", + "pestControl", + "powerConsumptionReport", + "powerMeter", + "powerSource", + "presenceSensor", + "pumpControlMode", + "pumpOperationMode", + "radonHealthConcern", + "radonMeasurement", + "rainSensor", + "refresh", + "relativeHumidityMeasurement", + "remoteControlStatus", + "robotCleanerOperatingState", + "safetyValve", + "serviceArea", + "signalStrength", + "smokeDetector", + "soundSensor", + "sounds", + "statelessColorTemperatureStep", + "statelessCurtainPowerButton", + "statelessSwitchLevelStep", + "switch", + "switchLevel", + "tamperAlert", + "temperatureAlarm", + "temperatureLevel", + "temperatureMeasurement", + "temperatureSetpoint", + "thermostatCoolingSetpoint", + "thermostatFanMode", + "thermostatHeatingSetpoint", + "thermostatMode", + "thermostatOperatingState", + "threadBorderRouter", + "threadNetwork", + "threeAxis", + "tone", + "tvocHealthConcern", + "tvocMeasurement", + "ultravioletIndex", + "valve", + "veryFineDustHealthConcern", + "veryFineDustSensor", + "videoCapture2", + "videoStreamSettings", + "voltageMeasurement", + "waterFlowAlarm", + "waterMeter", + "waterSensor", + "webrtc", + "wifiInformation", + "windMode", + "windowShade", + "windowShadeLevel", + "windowShadePreset", + "windowShadeTiltLevel", + "zoneManagement", + "zwMultichannel", +]) + + +def build_headers(pat: str) -> Dict: + return { + "Authorization": f"Bearer {pat}", + "Accept": "application/vnd.smartthings+json;v=1, application/json", + "Content-Type": "application/json", + } + + +def scan_profiles(drivers_dirs: List[Path]) -> Set[Tuple[str, int]]: + """ + Walk every drivers_dir, find all *.yml files under any profiles/ subdirectory, + parse them with PyYAML, and extract (capabilityId, version) pairs. + + Returns a deduplicated set of (id, version) tuples. + """ + found: Set[Tuple[str, int]] = set() + profile_count = 0 + error_count = 0 + + for drivers_dir in drivers_dirs: + for profile_path in drivers_dir.rglob("profiles/*.yml"): + profile_count += 1 + try: + with open(profile_path, encoding="utf-8") as f: + profile = yaml.safe_load(f) + if not profile or not isinstance(profile.get("components"), list): + continue + for component in profile["components"]: + for cap in component.get("capabilities") or []: + cap_id = cap.get("id") + cap_ver = cap.get("version", 1) + if cap_id: + found.add((cap_id, int(cap_ver))) + except Exception as exc: + print(f" WARNING: failed to parse {profile_path}: {exc}", file=sys.stderr) + error_count += 1 + + print( + f"Scanned {profile_count} profile(s) across {len(drivers_dirs)} driver tree(s)" + + (f", {error_count} parse error(s)" if error_count else "") + + "." + ) + return found + + +def output_path(output_dir: Path, cap_id: str, cap_ver: int) -> Path: + return output_dir / f"{cap_id}_{cap_ver}.json" + + +def fetch_definitions( + pairs: List[Tuple[str, int]], + headers: Dict, + max_retries: int = 3, + retry_delay: float = 2.0, +) -> Tuple[Dict[Tuple[str, int], Dict], List[Tuple[str, int, str]]]: + """ + Fetch capability definitions for the given (id, version) pairs. + + Pairs whose ID is in QUERY_ENDPOINT_IDS are fetched via a single bulk POST + to /capabilities/query. All other pairs are fetched individually via + GET /capabilities//, so that an unsupported capability causes + only an isolated failure rather than affecting the entire batch. + + Any capability that is missing from the bulk query response (e.g. due to an + intermittent API hiccup) is retried individually via GET up to max_retries + times before being recorded as a failure. + + Returns a tuple of: + - dict mapping (id, version) -> definition object for successful fetches + - list of (id, version, reason) tuples for failed fetches + """ + results: Dict[Tuple[str, int], Dict] = {} + failures: List[Tuple[str, int, str]] = [] + if not pairs: + return results, failures + + # Split into bulk-query pairs and individual-GET pairs + query_pairs = [(cid, ver) for cid, ver in pairs if cid in QUERY_ENDPOINT_IDS] + get_pairs = [(cid, ver) for cid, ver in pairs if cid not in QUERY_ENDPOINT_IDS] + + # --- Bulk query for known-good capabilities --- + if query_pairs: + query = [{"capabilityId": cid, "version": ver} for cid, ver in query_pairs] + response = requests.post(QUERY_ENDPOINT, headers=headers, json={"query": query}, timeout=30) + + if response.status_code == 200: + for item in response.json().get("items", []): + results[(item["id"], item["version"])] = item + # Any pair missing from the response gets retried individually - the + # API occasionally omits items from the bulk response transiently. + returned = {(item["id"], item["version"]) for item in response.json().get("items", [])} + missing = [(cid, ver) for cid, ver in query_pairs if (cid, ver) not in returned] + if missing: + print( + f" WARNING: {len(missing)} capability/version pair(s) missing from bulk query " + "response — retrying individually..." + ) + for cap_id, cap_ver in missing: + _fetch_individual(cap_id, cap_ver, headers, results, failures, + max_retries, retry_delay) + else: + print( + f" WARNING: bulk query returned HTTP {response.status_code} — " + "marking all query-endpoint capabilities as failed." + ) + for cap_id, cap_ver in query_pairs: + failures.append((cap_id, cap_ver, f"HTTP {response.status_code}")) + + # --- Individual GET for all other capabilities --- + if get_pairs: + print(f" Fetching {len(get_pairs)} capability/version pair(s) via individual GET...") + for cap_id, cap_ver in get_pairs: + _fetch_individual(cap_id, cap_ver, headers, results, failures, + max_retries, retry_delay) + + return results, failures + + +def _fetch_individual( + cap_id: str, + cap_ver: int, + headers: Dict, + results: Dict, + failures: List, + max_retries: int = 3, + retry_delay: float = 2.0, +) -> None: + """ + Fetch a single capability definition via GET /capabilities//, + retrying up to max_retries times on failure with exponential backoff. + Appends to results on success or failures on final failure. + """ + last_status = None + for attempt in range(1, max_retries + 1): + r = requests.get( + f"{API_BASE}/capabilities/{cap_id}/{cap_ver}", + headers=headers, + timeout=30, + ) + if r.status_code == 200: + results[(cap_id, cap_ver)] = r.json() + return + last_status = r.status_code + if attempt < max_retries: + wait = retry_delay * attempt + print( + f" WARNING: {cap_id} v{cap_ver} — HTTP {last_status} " + f"(attempt {attempt}/{max_retries}, retrying in {wait:.0f}s...)" + ) + time.sleep(wait) + + print(f" WARNING: skipping {cap_id} v{cap_ver} — HTTP {last_status} after {max_retries} attempts.") + failures.append((cap_id, cap_ver, f"HTTP {last_status}")) + + +def write_failure_report( + failures: List[Tuple[str, int, str]], + output_file: Path, + truncate_at: int = 10, +) -> None: + """ + Write a markdown-formatted failure report suitable for use as a PR comment. + + The file is only written when there are failures. If failures is empty, + this function does nothing (leaving any previously existing file untouched, + but no file will be created for a clean run). + """ + if not failures: + return + + lines = [ + "## ⚠️ Capability Definition Download Failures", + "", + "The following capability definitions could not be downloaded from the SmartThings API:", + "", + "| Capability ID | Version | Reason |", + "|--------------|---------|--------|", + ] + + shown = failures[:truncate_at] + for cap_id, cap_ver, reason in shown: + lines.append(f"| `{cap_id}` | {cap_ver} | {reason} |") + + if len(failures) > truncate_at: + lines.append("") + lines.append(f"_...and {len(failures) - truncate_at} more._") + + lines += [ + "", + "These capabilities may not be available in the test environment. " + "Tests using these capabilities may fail or fall back to built-in definitions.", + ] + + output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--drivers-dir", + action="append", + dest="drivers_dirs", + metavar="PATH", + required=True, + help="Root of a driver tree to scan (may be specified multiple times).", + ) + parser.add_argument( + "--output-dir", + metavar="PATH", + required=True, + help="Directory to write _.json files.", + ) + parser.add_argument( + "--overwrite", + action="store_true", + default=False, + help=( + "Re-download and overwrite all capability files even if they already " + "exist. Without this flag only missing files are fetched." + ), + ) + parser.add_argument( + "--failed-output-file", + metavar="PATH", + default=None, + help=( + "When set, write a markdown-formatted report of any capabilities that " + "could not be downloaded to this file. The file is only created when " + "there are failures, making it suitable for use as a PR comment via " + "hashFiles() checks in CI workflows." + ), + ) + args = parser.parse_args(argv) + + pat = os.environ.get("CAPABILITY_PAT", "") + if not pat: + print("ERROR: CAPABILITY_PAT environment variable is not set.", file=sys.stderr) + sys.exit(1) + + headers = build_headers(pat) + output_dir = Path(args.output_dir).expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + drivers_dirs = [Path(d).expanduser().resolve() for d in args.drivers_dirs] + for d in drivers_dirs: + if not d.is_dir(): + print(f"ERROR: --drivers-dir does not exist: {d}", file=sys.stderr) + sys.exit(1) + + # Step 1: scan profiles + all_pairs = scan_profiles(drivers_dirs) + print(f"Found {len(all_pairs)} unique capability/version pair(s).") + + # Step 2: filter known-bad IDs + excluded = {p for p in all_pairs if p[0] in EXCLUDED_IDS} + for cap_id, cap_ver in sorted(excluded): + print( + f" WARNING: excluding {cap_id} v{cap_ver} " + "(rejected by the query endpoint — likely a duplicate/malformed entry)." + ) + all_pairs -= excluded + + # Step 3: resolve what to fetch + if args.overwrite: + to_fetch = sorted(all_pairs) + already_present = 0 + else: + to_fetch = sorted(p for p in all_pairs if not output_path(output_dir, p[0], p[1]).exists()) + already_present = len(all_pairs) - len(to_fetch) + + if already_present: + print(f"{already_present} capability/version pair(s) already on disk — skipping.") + + if not to_fetch: + print("Nothing to fetch. Output directory is up to date.") + _print_summary(len(drivers_dirs), len(all_pairs) + len(excluded), len(excluded), + already_present, 0, 0, 0, output_dir) + return + + print(f"Fetching {len(to_fetch)} capability definition(s)...") + + # Step 4: fetch + definitions, failures = fetch_definitions(to_fetch, headers) + + # Step 5: write + written = 0 + skipped_api = 0 + for cap_id, cap_ver in to_fetch: + definition = definitions.get((cap_id, cap_ver)) + if definition is None: + skipped_api += 1 + continue + dest = output_path(output_dir, cap_id, cap_ver) + dest.write_text(json.dumps(definition, indent=2), encoding="utf-8") + written += 1 + + _print_summary(len(drivers_dirs), len(all_pairs) + len(excluded), len(excluded), + already_present, len(to_fetch), written, skipped_api, output_dir) + + # Step 6: write failure report if requested + if args.failed_output_file: + failed_output_file = Path(args.failed_output_file).expanduser().resolve() + write_failure_report(failures, failed_output_file) + if failures: + print(f" Failure report written to: {failed_output_file}") + + +def _print_summary( + trees: int, + total_found: int, + excluded: int, + already_present: int, + requested: int, + written: int, + skipped_api: int, + output_dir: Path, +) -> None: + print() + print("=== Summary ===") + print(f" Driver trees scanned : {trees}") + print(f" Unique capabilities : {total_found}") + if excluded: + print(f" Excluded (bad IDs) : {excluded}") + print(f" Already on disk : {already_present}") + print(f" Requested from API : {requested}") + print(f" Written : {written}") + if skipped_api: + print(f" Skipped (API error) : {skipped_api}") + print(f" Output directory : {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/tools/run_driver_tests.py b/tools/run_driver_tests.py index e686a0f4b7..9020bba05b 100755 --- a/tools/run_driver_tests.py +++ b/tools/run_driver_tests.py @@ -38,6 +38,10 @@ def run_tests(verbosity_level, filter, junit, coverage_files, html): total_tests = 0 total_passes = 0 drivers_needing_html = {} + # Propagate ST_CAPABILITY_JSON_DIR so the mock capability channel can load + # capability definitions from pre-fetched JSON files produced by + # tools/fetch_capability_definitions.py. + env = os.environ.copy() for test_file in DRIVER_DIR.glob("*" + os.path.sep + "*" + os.path.sep + "src" + os.path.sep + "test" + os.path.sep + "test_*.lua"): if filter != None and re.search(filter, str(test_file)) is None: continue @@ -46,9 +50,9 @@ def run_tests(verbosity_level, filter, junit, coverage_files, html): print("#" * len(test_line)) print(test_line) if test_file in coverage_files: - a = subprocess.run("lua -lluacov {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + a = subprocess.run("lua -lluacov {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env) else: - a = subprocess.run("lua {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + a = subprocess.run("lua {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env) lines = a.stdout.decode().split("\n") test_count = 0 passes = 0 diff --git a/tools/run_driver_tests_p.py b/tools/run_driver_tests_p.py index 16180f7682..d19bf46716 100644 --- a/tools/run_driver_tests_p.py +++ b/tools/run_driver_tests_p.py @@ -29,15 +29,19 @@ def per_driver_task(driver_dir): else: failure_output = None if driver_dir.name in CHANGED_DRIVERS: - with driver_dir.parent.parent.parent.joinpath("tools/coverage_output").joinpath(driver_dir.name+"_coverage.xml") as outfile: - subprocess.run("luacov-cobertura -o {} -c {}".format(outfile, LUACOV_CONFIG), shell=True) + outfile = driver_dir.parent.parent.parent.joinpath("tools/coverage_output").joinpath(driver_dir.name+"_coverage.xml") + subprocess.run("luacov-cobertura -o {} -c {}".format(outfile, LUACOV_CONFIG), shell=True) return failure_output def run_test(test_file): + # Propagate ST_CAPABILITY_JSON_DIR so the mock capability channel can load + # capability definitions from pre-fetched JSON files produced by + # tools/fetch_capability_definitions.py. + env = os.environ.copy() if test_file.parent.parent.parent.name in CHANGED_DRIVERS: - a = subprocess.run("lua -lluacov {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + a = subprocess.run("lua -lluacov {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env) else: - a = subprocess.run("lua {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + a = subprocess.run("lua {}".format(test_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env) error = a.stderr.decode() if error and error != "": print(error)