diff --git a/.agents/skills/dev-workflow/SKILL.md b/.agents/skills/dev-workflow/SKILL.md new file mode 100644 index 0000000000..23f4ce17df --- /dev/null +++ b/.agents/skills/dev-workflow/SKILL.md @@ -0,0 +1,264 @@ +--- +name: dev-workflow +description: Setting up the development environment, deploying Edge Drivers to hubs, and sharing drivers with other users via channels and invites +--- + +# SmartThings Edge Driver Development Workflow + +This skill covers environment setup, driver deployment to hubs, and sharing +drivers with other users through channels and invite links. + +--- + +## Environment Setup + +### 1. Install Lua 5.3 + +Edge Drivers are Lua-based. Install the Lua 5.3 runtime for local development +and linting: + +```bash +# Ubuntu / Debian +sudo apt install lua5.3 + +# macOS +brew install lua@5.3 + +# Windows +# Download the Lua 5.3 binary from https://luabinaries.sourceforge.net/download.html +# Or install via scoop: +scoop install lua +# Or via chocolatey: +choco install lua53 +``` + +### 2. lua_libs Directory + +The `lua_libs/` directory contains the SmartThings Lua libraries that are +available on the hub at runtime. These correspond to the assets attached to the +latest release on GitHub: + + + +Download the lua_libs archive from the release assets and +extract it into the repository root if it is missing or needs updating. + +### 3. Configure LUA_PATH + +Set `LUA_PATH` so that `require` resolves both your driver modules and the +SmartThings library modules in `lua_libs/`: + +```bash +export LUA_PATH="./?.lua;./?/init.lua;$(pwd)/lua_libs/?.lua;$(pwd)/lua_libs/?/init.lua;;" +``` + +Run it from the repository root so `$(pwd)` resolves correctly. + + +### 4. Install the SmartThings CLI + +The CLI is required for packaging, deploying, and managing drivers and +channels on the platform. + +```bash +# Via npm (requires Node.js >= 24.8.0) +npm install -g @smartthings/cli + +# macOS via Homebrew +brew install smartthingscommunity/smartthings/smartthings + +# Linux / Windows +# Download the binary or installer from: +# https://github.com/SmartThingsCommunity/smartthings-cli/releases +``` + +Verify the installation: + +```bash +smartthings --version +``` + +The CLI uses browser-based OAuth login by default. Run `smartthings devices` to trigger +the login flow. + +### 5. Python Requirements (Testing) + +Some test and tooling scripts require Python dependencies: + +```bash +pip install -r tools/requirements.txt +``` + +### 6. Install Luacheck (Linting) + +Luacheck provides static analysis for Lua source files. It requires LuaRocks +(the Lua package manager). + +**Install LuaRocks first:** + +```bash +# Ubuntu / Debian +sudo apt install luarocks + +# macOS +brew install luarocks + +# Windows +# Download the installer from https://luarocks.org/releases/ +# Or via chocolatey: +choco install luarocks +``` + +**Then install Luacheck:** + +```bash +# Via LuaRocks (all platforms) +luarocks install luacheck + +# macOS alternative (installs both luarocks and luacheck) +brew install luacheck +``` + +Run it against a driver directory: + +```bash +luacheck --config .github/workflows/.luacheckrc drivers/SmartThings/zigbee-switch/ +``` + +--- + +## Deploying Drivers + +### Overview + +Deploying a driver to a physical hub requires three things: + +1. A **channel** you own. +2. The hub **enrolled** in that channel. +3. The driver **packaged and uploaded** through the CLI. + +### Step 1: Create a Channel + +```bash +smartthings edge:channels:create +``` + +You will be prompted for a name and description. Note the returned channel ID. + +### Step 2: Enroll Your Hub + +```bash +smartthings edge:channels:enroll +``` + +Select the channel when prompted, or pass `--channel `. + +Find your hub ID with: + +```bash +smartthings devices --type HUB +``` + +### Step 3: Package and Install the Driver + +The `edge:drivers:package` command can build, upload, assign to a channel, and +install in one step: + +```bash +smartthings edge:drivers:package \ + --hub= \ + --channel= +``` + +For example: + +```bash +smartthings edge:drivers:package drivers/SmartThings/zwave-switch \ + --hub=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee \ + --channel=11111111-2222-3333-4444-555555555555 +``` + +### Other Useful Deployment Commands + +```bash +# List drivers installed on a hub +smartthings edge:drivers:installed --hub= + +# Stream logs from a driver on the hub +smartthings edge:drivers:logcat --hub= + +# Uninstall a driver from a hub +smartthings edge:drivers:uninstall --hub= + +# Remove unused drivers from a hub +smartthings edge:drivers:prune --hub= + +# Switch a device to a different driver +smartthings edge:drivers:switch +``` + +--- + +## Sharing Drivers + +### Creating an Invite Link + +Invite links let other users install your driver from your channel without +giving them ownership of the driver or channel. + +```bash +smartthings edge:channels:invites:create +``` + +You will be prompted to select a channel and a driver. The command returns an +invite URL of the form: + +``` +https://bestow-regional.api.smartthings.com/invite/ +``` + +Share this URL with users. They open it in a browser or the SmartThings mobile +app to accept the invitation. + +### Enrollment Flow for Recipients + +1. The recipient opens the invite link. +2. They log in to their Samsung / SmartThings account. +3. They select a hub to enroll in the channel. +4. The driver can be selected to install to that hub. + +### Managing Invites + +```bash +# List existing invites +smartthings edge:channels:invites + +# Delete an invite +smartthings edge:channels:invites:delete +``` + +### Managing Channel Assignments + +```bash +# Assign a specific driver version to a channel +smartthings edge:channels:assign + +# List drivers assigned to a channel +smartthings edge:channels:drivers + +# Remove a driver from a channel +smartthings edge:channels:unassign +``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| Create channel | `smartthings edge:channels:create` | +| Enroll hub | `smartthings edge:channels:enroll ` | +| Package & deploy | `smartthings edge:drivers:package --hub= --channel=` | +| Stream logs | `smartthings edge:drivers:logcat --hub=` | +| Create invite | `smartthings edge:channels:invites:create` | +| List installed drivers | `smartthings edge:drivers:installed --hub=` | diff --git a/.agents/skills/linting-and-style/SKILL.md b/.agents/skills/linting-and-style/SKILL.md new file mode 100644 index 0000000000..83578854ce --- /dev/null +++ b/.agents/skills/linting-and-style/SKILL.md @@ -0,0 +1,96 @@ +--- +name: linting-and-style +description: Running luacheck for Lua linting and following code style conventions in Edge Driver development +--- + +# Linting and Code Style for Edge Drivers + +## Running Luacheck + +```bash +luacheck --config .github/workflows/.luacheckrc +``` + +### Examples + +```bash +# Lint a specific driver +luacheck --config .github/workflows/.luacheckrc drivers/SmartThings/zigbee-switch/ + +# Lint a single file +luacheck --config .github/workflows/.luacheckrc drivers/SmartThings/zigbee-switch/src/init.lua + +# Lint the entire repo +luacheck --config .github/workflows/.luacheckrc . +``` + +Luacheck runs automatically in CI on pull requests that modify files under `drivers/` (see `.github/workflows/luacheck.yml`). + +## Code Style Conventions + +These conventions are observed across the Edge Driver codebase: + +### General + +- **Indentation**: 2 spaces, no tabs +- **Strings**: Use double quotes `"string"` for module requires and general strings +- **Local variables**: Always use `local` for variables and functions at module scope +- **Line length**: No enforced limit, but most code stays under 120 characters + +### Naming + +- **Variables and functions**: `snake_case` (e.g., `local mock_device`, `local function test_init()`) +- **Constants**: `UPPER_SNAKE_CASE` for true constants (e.g., `SENSOR_BINARY`) +- **Modules**: Return a table at the end of the file (`return module_name`) + +### Requires and Imports + +```lua +-- Standard library requires first +local capabilities = require "st.capabilities" +local zw = require "st.zwave" + +-- Then test/integration requires +local test = require "integration_test" +local t_utils = require "integration_test.utils" + +-- Then protocol-specific requires +local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) +``` + +### Function Style + +- Prefer `local function name()` over `local name = function()` +- Handler functions typically receive `(driver, device, ...)` arguments +- Use early returns for guard clauses + +### Tables + +- Trailing commas are common and acceptable in multi-line tables +- Align table entries for readability in test manifests + +### Comments + +- Use `--` for single-line comments +- Minimal inline comments; code should be self-documenting + +Copyright header at the top of every file: +```lua +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +``` + +### File Organization for Drivers + +``` +driver-name/ + src/ + init.lua -- Main driver entry point + .lua -- Additional driver modules + test/ + test_*.lua -- Test files (must start with test_) + profiles/ + *.yml -- Device profiles + fingerprints.yml -- Device fingerprints + config.yml -- Driver configuration +``` diff --git a/.agents/skills/testing-edge-drivers/SKILL.md b/.agents/skills/testing-edge-drivers/SKILL.md new file mode 100644 index 0000000000..a34a22c901 --- /dev/null +++ b/.agents/skills/testing-edge-drivers/SKILL.md @@ -0,0 +1,349 @@ +--- +name: testing-edge-drivers +description: Running and writing integration tests for SmartThings Edge Drivers using the Python test harness and Lua integration test framework +--- + +# Testing SmartThings Edge Drivers + +## Running Tests + +Tests are run via the Python test harness: + +```bash +python3 tools/run_driver_tests.py [options] +``` + +### Options + +| Flag | Description | +|------|-------------| +| `-v` | Print individual test names and pass/fail status | +| `-vv` | Print test names, status, and full logs on failures (recommended) | +| `-vvv` | Print all logs from all tests | +| `-f ` | Only run tests whose file path matches the regex filter | +| `-j ` | Output JUnit XML results to the specified file | +| `-c [files]` | Run with luacov code coverage | +| `--html` | Generate HTML coverage reports (use with `-c`) | + +### Filter Examples + +```bash +# Run all tests for a specific driver +python3 tools/run_driver_tests.py -vv -f "zwave-smoke-alarm" + +# Run a specific test file +python3 tools/run_driver_tests.py -vv -f "test_zwave_smoke_detector" + +# Run all zigbee switch tests +python3 tools/run_driver_tests.py -vv -f "zigbee-switch" + +# Run all virtual device tests +python3 tools/run_driver_tests.py -vv -f "virtual" +``` + +The filter is a regex applied to the full file path. The harness searches for files matching `drivers/*/*/src/test/test_*.lua`. + +### Python Requirements + +Install dependencies before running tests: + +```bash +pip install -r tools/requirements.txt +``` + +Required packages: `junit_xml`, `requests`, `PyYAML`, `regex`. + +### How Tests Execute + +The Python harness (`tools/run_driver_tests.py`): +1. Globs for all `test_*.lua` files under `drivers/*/src/test/` +2. Filters by the `-f` regex if provided +3. Changes directory to the driver's `src/` directory (two levels up from the test file) +4. Runs each test file with `lua ` +5. Parses stdout for `Running test`, `PASSED`, `FAILED`, and summary lines +6. Reports totals and exits with code 1 if any tests failed + +## Integration Test Framework + +The framework lives in `lua_libs/integration_test/` and is required as `integration_test` in test files. It provides: + +### Core Modules + +| Module | Purpose | +|--------|---------| +| `integration_test` (init.lua) | Main test runner, registration, mock device builder | +| `integration_test.utils` | Utility functions like `get_profile_definition()` | +| `integration_test.mock_device` | Build mock Zigbee, Z-Wave, Matter, or generic devices | +| `integration_test.zwave_test_utils` | Z-Wave specific helpers (e.g., `zwave_test_build_receive_command`) | +| `integration_test.zigbee_test_utils` | Zigbee specific helpers | +| `integration_test.mock_socket` | Mock socket layer with channel-based message routing | + +### Channels + +The test framework uses channels to simulate communication between the driver and the platform: + +- `zwave` - Z-Wave protocol messages +- `zigbee` - Zigbee protocol messages +- `matter` - Matter protocol messages +- `capability` - SmartThings capability events (commands from cloud, events to cloud) +- `device_lifecycle` - Device lifecycle events (init, added, removed, etc.) +- `driver_lifecycle` - Driver lifecycle events +- `timer` - Timer-related events + +Each channel supports two directions: +- `receive` - Messages sent TO the driver (incoming commands, device reports) +- `send` - Messages sent FROM the driver (capability events, protocol commands) + +## Writing Tests + +### Test File Structure + +Every test file follows this pattern: + +```lua +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +-- 1. Build mock device(s) +local mock_device = test.mock_device.build_test_generic_device({ + profile = t_utils.get_profile_definition("my-profile.yml"), +}) + +-- 2. Define test init function (runs before each test) +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- 3. Register tests (message tests or coroutine tests) + +-- 4. Run all registered tests +test.run_registered_tests() +``` + +### Building Mock Devices + +```lua +-- Generic device (no protocol) +local mock = test.mock_device.build_test_generic_device({ + profile = t_utils.get_profile_definition("profile-name.yml"), + preferences = { ["certifiedpreferences.somePref"] = true }, +}) + +-- Z-Wave device +local zw = require "st.zwave" +local mock = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("profile-name.yml"), + zwave_endpoints = { + { + command_classes = { + { value = zw.SENSOR_BINARY }, + { value = zw.NOTIFICATION }, + } + } + } +}) + +-- Zigbee device +local mock = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("profile-name.yml"), + zigbee_endpoints = { ... } +}) +``` + +### Message Tests (`register_message_test`) + +Message tests define an ordered sequence of receive/send message pairs. Each receive triggers the driver handler, and the subsequent sends are the expected outputs. + +```lua +test.register_message_test( + "Test description", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 17 -- optional version constraint + } +) +``` + +The manifest is an array of message entries. The framework groups them into blocks: each block starts with a `receive` followed by zero or more `send` entries. The receives are queued on the mock channel; the sends are set as expectations. The driver processes the receive and the framework asserts the expected sends occurred. + +### Coroutine Tests (`register_coroutine_test`) + +For more complex test logic (multiple interactions, state changes, conditional assertions, timer manipulation): + +```lua +test.register_coroutine_test( + "Test with complex logic", + function() + -- Queue a lifecycle event + test.socket.device_lifecycle():__queue_receive({ mock_device.id, "init" }) + test.socket.device_lifecycle():__queue_receive( + mock_device:generate_info_changed({ + preferences = { ["certifiedpreferences.somePref"] = false } + }) + ) + test.wait_for_events() + + -- Now send a capability command and expect a response + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.switch.switch.on()) + ) + end, + { + min_api_version = 17 + } +) +``` + +Key coroutine test APIs: +- `test.socket.:__queue_receive(msg)` - Queue a message for the driver to receive +- `test.socket.:__expect_send(msg)` - Set an expectation for a message the driver should send +- `test.wait_for_events()` - Yield to let the driver process queued messages and check expectations +- `test.mock_time.advance_time(seconds)` - Advance the mock clock + +### Real Example: Z-Wave Smoke Detector Test + +From `drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua`: + +```lua +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" + +local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) + +local sensor_endpoints = { + { + command_classes = { + { value = zw.SENSOR_BINARY }, + { value = zw.SENSOR_ALARM }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("smoke-battery-temperature-tamperalert-temperaturealarm.yml"), + zwave_endpoints = sensor_endpoints +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Sensor Binary report (smoke) should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + zw_test_utils.zwave_test_build_receive_command( + SensorBinary:Report({ + sensor_type = SensorBinary.sensor_type.SMOKE, + sensor_value = SensorBinary.sensor_value.DETECTED_AN_EVENT + }) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.smokeDetector.smoke.detected()) + } + }, + { min_api_version = 17 } +) + +test.run_registered_tests() +``` + +## Common Test Patterns + +### Testing Capability Commands (cloud -> device) + +Receive on `capability` channel, expect protocol message on `zwave`/`zigbee`/`matter`: + +```lua +{ + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } } +}, +{ + channel = "zwave", + direction = "send", + message = ... -- expected Z-Wave command +} +``` + +### Testing Device Reports (device -> cloud) + +Receive on protocol channel, expect capability event on `capability`: + +```lua +{ + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(...) } +}, +{ + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) +} +``` + +### Testing Lifecycle Events + +```lua +test.socket.device_lifecycle():__queue_receive({ mock_device.id, "added" }) +test.socket.device_lifecycle():__queue_receive({ mock_device.id, "init" }) +test.socket.device_lifecycle():__queue_receive({ mock_device.id, "doConfigure" }) +``` + +### Testing Preference Changes + +```lua +test.socket.device_lifecycle():__queue_receive( + mock_device:generate_info_changed({ + preferences = { ["certifiedpreferences.myPref"] = new_value } + }) +) +``` + +### Optional Test Parameters + +The `opts` table passed to `register_message_test` or `register_coroutine_test` supports: + +| Field | Description | +|-------|-------------| +| `min_api_version` | Skip test if API version is below this (commonly set to 17) | +| `max_api_version` | Skip test if API version is above this | +| `test_init` | Per-test init function (overrides the global `set_test_init_function`) | +| `expected_error` | String or array of Lua patterns for expected errors | +| `inner_block_ordering` | Set to `"relaxed"` to allow sends in any order within a block | diff --git a/.agents/skills/understanding-lua-libraries/SKILL.md b/.agents/skills/understanding-lua-libraries/SKILL.md new file mode 100644 index 0000000000..235f637b69 --- /dev/null +++ b/.agents/skills/understanding-lua-libraries/SKILL.md @@ -0,0 +1,295 @@ +--- +name: understanding-lua-libraries +description: Understanding the SmartThings Edge Driver Lua libraries - driver lifecycle, message dispatchers, default handlers, and protocol message objects +--- + +# SmartThings Edge Driver Lua Library Architecture + +## 1. Driver Initialization and Run Loop + +A driver is created by calling `Driver("name", template)` (or a protocol-specific variant like `ZigbeeDriver("name", template)`). The template is a Lua table containing handler tables and configuration. + +The base `Driver.init` (in `lua_libs/st/driver.lua`) does the following: +- Sets `out_driver.NAME` from the name argument +- Initializes handler tables: `capability_handlers`, `lifecycle_handlers`, `message_handlers` +- Opens communication channels via cosock sockets: `capability_channel`, `environment_channel`, `lifecycle_channel`, `driver_lifecycle_channel`, and optionally `discovery_channel` +- Initializes a datastore and device cache tables +- Calls `Driver.standardize_sub_drivers()` to normalize the `sub_drivers` list +- Builds the `lifecycle_dispatcher` and `capability_dispatcher` from handlers + sub_drivers +- Registers channel handlers so inbound messages get routed to the correct handler function + +The `driver:run()` call starts the cosock event loop, which runs forever processing messages from all registered channels. + +## 2. Message Dispatchers + +The dispatcher system (`lua_libs/st/dispatcher.lua`) is a hierarchical message routing tree. The base class `MessageDispatcher` provides: + +- **`default_handlers`** - handlers at this level of the hierarchy. +- **`child_dispatchers`** - sub-dispatchers (from sub_drivers) that may override defaults +- **`can_handle(driver, device, ...)`** - returns true if this dispatcher or a child can handle the message +- **`dispatch(driver, device, ...)`** - finds and executes the matching handler + +### Dispatch logic + +1. The dispatcher calls `can_handle` on each child dispatcher +2. If any children can handle: **only the children handle it** (parent defaults are NOT called) +3. If multiple children match: ALL matching children receive the message +4. If NO children match: parent defaults are used +5. This is recursive -- sub-drivers can have sub-drivers + +### Dispatcher types + +| Dispatcher | Class | Handles | +|------------|-------|---------| +| `capability_dispatcher` | `CapabilityCommandDispatcher` | Capability commands from the platform (on, off, setLevel, etc.) | +| `lifecycle_dispatcher` | `DeviceLifecycleDispatcher` | Device lifecycle events (added, init, removed, etc.) | +| `zigbee_message_dispatcher` | `ZigbeeMessageDispatcher` | Incoming Zigbee messages (attribute reports, cluster commands, ZDO) | +| `zwave_dispatcher` | `ZwaveDispatcher` | Incoming Z-Wave commands | +| `matter_dispatcher` | `MatterMessageDispatcher` | Incoming Matter interaction responses | +| `secret_data_dispatcher` | `SecretDataDispatcher` | Security/secret data events | + +Each protocol-specific driver (ZigbeeDriver, ZwaveDriver, MatterDriver) adds its own dispatcher on top of the base Driver's capability and lifecycle dispatchers. + +**Zigbee handler structure:** +```lua +zigbee_handlers = { + attr = { -- attribute reports / read responses + [ClusterID] = { + [AttributeID] = handler_function, + } + }, + global = { -- global ZCL commands + [ClusterID] = { + [CommandID] = handler_function, + } + }, + cluster = { -- cluster-specific commands + [ClusterID] = { + [CommandID] = handler_function, + } + }, + zdo = { -- ZDO commands + [ClusterID] = handler_function, + } +} +``` + +**Z-Wave handler structure:** +```lua +zwave_handlers = { + [cc.SWITCH_BINARY] = { -- command class + [SwitchBinary.REPORT] = handler_function, -- command ID + }, +} +``` + +**Matter handler structure:** +```lua +matter_handlers = { + attr = { + [ClusterID] = { + [AttributeID] = handler_function, + } + }, + cmd_response = { ... }, + event = { ... }, + fallback = handler_function, +} +``` + +**Capability handler structure:** +```lua +capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = handle_on, + [capabilities.switch.commands.off.NAME] = handle_off, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = handle_set_level, + }, +} +``` + +## 3. Sub-Drivers Pattern + +Sub-drivers allow device-specific behavior overrides gated by a `can_handle` function. A sub-driver is a table with: +- `NAME` (string) +- `can_handle(opts, driver, device, ...) -> boolean` +- Protocol handlers (zigbee_handlers, zwave_handlers, matter_handlers) +- `capability_handlers`, `lifecycle_handlers` +- Optional nested `sub_drivers` + +In practice, sub-drivers are often organized as separate files under `src/sub_drivers/` for clarity, and required in the main driver template. + + +### Dispatch Logic + +1. The dispatcher calls `can_handle` on each child dispatcher +2. If any children can handle: **only the children handle it** (parent defaults are NOT called) +3. If multiple children match: ALL matching children receive the message +4. If NO children match: parent defaults are used +5. This is recursive -- sub-drivers can have sub-drivers + +### Lazy Loading + +Sub-drivers support lazy loading for memory optimization: +- `Driver.lazy_load_sub_driver(sub_driver)`: Strips handlers, keeps only `can_handle` and `NAME` +- `Driver.lazy_load_sub_driver_v2(require_path)`: Even more efficient; only requires `can_handle` and `sub_drivers` modules separately +- A sub-driver with no handlers defined is automatically treated as lazy-loadable + +New sub-drivers must be: +1. Listed in the parent's `sub_drivers.lua` (or the equivalent sub_drivers table) +2. Have a `can_handle.lua` that correctly identifies the target devices +3. Have an `init.lua` that returns the sub-driver table + +If any of these are missing, the sub-driver will not be loaded. + + +## 4. Lifecycle Events + +Device lifecycle events are dispatched through the `DeviceLifecycleDispatcher`. The key events: + +1. **`init`** -- Called for every device on driver startup (existing devices) and after `added` for new devices. Used for setting up component/endpoint mappings and device fields. +2. **`added`** -- Called only when a device is first paired. NOT called for existing devices when a driver is updated. After `added`, a synthetic `init` is automatically dispatched. +3. **`doConfigure`** -- Called when the device needs configuration (typically after pairing). +4. **`infoChanged`** -- Called when device metadata changes (e.g., preferences updated). Receives `args.old_st_store` for comparison. +5. **`removed`** -- Called when device is removed. +6. **`driverSwitched`** -- Called when device switches to this driver. + +Register lifecycle handlers in the template: +```lua +lifecycle_handlers = { + init = device_init, + added = device_added, + removed = device_removed, + doConfigure = device_do_configure, + infoChanged = info_changed_handler, +} +``` + +Handler signature: `function(driver, device, event, args)` + +**Default behaviors provided by the framework:** +- `driverSwitched`: Base Driver marks device as `NONFUNCTIONAL`. ZigbeeDriver overrides this to check capability matching and marks as `PROVISIONED` if all capabilities match. +- `doConfigure`: ZigbeeDriver defaults to `device_management.configure` which sends attribute reporting configuration. +- `added`: After a successful `added` callback, the framework automatically queues a synthetic `init` event. +- `doConfigure`: After success, the framework transitions the device to `PROVISIONED` state. +- Unhandled lifecycle events log a trace message and are otherwise ignored (fallback handler). + +**Critical timing knowledge for lifecycle events** + +1. **`init` on driver startup**. After hub restart the radio may not be ready and sending Zigbee/Z-Wave commands in `init` can fail. +2. **`added` is NOT called for existing devices** on driver update. Only called on first pair. Code that must run for existing devices should go in `init` (for non-radio operations) or use `driverSwitched`. +3. **`doConfigure` is called any time a device is added with the TYPED provisioning state** and is the right place for device-specific configuration commands. +4. **`infoChanged` receives `args.old_st_store`** for comparing old vs new preferences. Drivers should check if a preference actually changed before acting on it. + +## 5. Key Imports and Require Paths + +```lua +-- Base driver (for virtual/LAN devices) +local Driver = require "st.driver" + +-- Protocol-specific drivers +local ZigbeeDriver = require "st.zigbee" +local ZwaveDriver = require "st.zwave.driver" +local MatterDriver = require "st.matter.driver" + +-- Capabilities +local capabilities = require "st.capabilities" + +-- Zigbee defaults (pre-built handlers for common capabilities) +local defaults = require "st.zigbee.defaults" + +-- Zigbee clusters (for building commands/reading attributes) +local zcl_clusters = require "st.zigbee.zcl" + +-- Z-Wave command classes +local cc = require "st.zwave.CommandClass" +local SwitchBinary = require "st.zwave.CommandClass.SwitchBinary" + +-- Matter clusters +local clusters = require "st.matter.clusters" + +-- Utilities +local utils = require "st.utils" +local json = require "st.json" +local log = require "log" + +-- Coroutine runtime +local cosock = require "cosock" + +-- LAN utils +local socket = cosock.socket +local luncheon = require "luncheon" +local luxure = require "luxure" +local lustre = require "lustre" +``` + +### Zigbee driver example (from zigbee-switch) + +```lua +local capabilities = require "st.capabilities" +local ZigbeeDriver = require "st.zigbee" +local defaults = require "st.zigbee.defaults" + +local template = { + supported_capabilities = { + capabilities.switch, + capabilities.switchLevel, + capabilities.colorControl, + capabilities.colorTemperature, + }, + sub_drivers = require("sub_drivers"), + lifecycle_handlers = { + init = device_init, + added = device_added, + }, +} + +-- Register default Zigbee handlers for all supported capabilities +defaults.register_for_default_handlers(template, + template.supported_capabilities, + {native_capability_cmds_enabled = true, native_capability_attrs_enabled = true} +) + +local driver = ZigbeeDriver("zigbee_switch", template) +driver:run() +``` + +This pattern - declare supported capabilities, register defaults, add overrides via sub_drivers and lifecycle_handlers, then construct and run - is the standard structure +for all protocol-based Edge drivers. + +## 6. Default Handlers and Protocol-Specific Default Functionality + +When a driver declares `supported_capabilities` in its template, the framework automatically registers default handlers for each capability. The registration uses `or`-merge +logic: **driver-defined handlers always take precedence over defaults.** If the driver already registered a handler for a given cluster/attribute/command slot, the default +is silently skipped. + +Registration happens in `st.{zigbee,zwave,matter}.defaults.init.lua` via `register_for_default_handlers(driver, capabilities, opts)`: +1. Iterates `supported_capabilities` +2. For each capability, requires the corresponding defaults module +3. Merges `zigbee_handlers`, `zwave_handlers`, or `matter_handlers` (only where driver hasn't defined one) +4. Also merges `attribute_configurations` (Zigbee), `get_refresh_commands` (Z-Wave), or `subscribed_attributes` (Matter) + +### Zigbee specific default functionality + +The default `doConfigure` handler (`device_management.configure`): +1. Sends a `refresh` command (reads all configured attributes) +2. Calls `device:configure()` which iterates all configured attributes and for each: + - Sends a ZDO Bind Request + - Sends a Configure Reporting command with the attribute's min/max interval, data type, and reportable change +3. Also handles IAS Zone enrollment if the device supports cluster `0x0500` + +### Z-Wave specific default functionality + +doConfigure calls `device:default_configure()` which calls `device:refresh()`. The default refresh iterates `get_refresh_commands` from all default capability modules and sends Get commands for each supported CC. +Refresh collects `get_refresh_commands` from all default modules, sends Get commands + +### Matter specific default functionality + +TODO + +## 7. Unit Test Framework + +Load the `testing-edge-drivers` skill for details on the built in unit test framework for to test Zigbee, Z-Wave, and Matter drivers. + diff --git a/.agents/skills/understanding-profiles/SKILL.md b/.agents/skills/understanding-profiles/SKILL.md new file mode 100644 index 0000000000..0f169057e5 --- /dev/null +++ b/.agents/skills/understanding-profiles/SKILL.md @@ -0,0 +1,369 @@ +--- +name: understanding-profiles +description: Understanding and defining SmartThings capabilities, device profiles, preferences, and embedded device configurations for Edge Drivers +--- + +# SmartThings Capabilities, Profiles, and Preferences + +## 1. What Are Capabilities? + +Capabilities are the fundamental abstraction in SmartThings. They define what a device can do and what state it can report. Each capability consists of: + +- **Attributes**: State/status values (e.g., `switch` has attribute `switch` with values `on`/`off`) +- **Commands**: Actions that control the device (e.g., `on()`, `off()`, `setLevel(level)`) + +A capability definition specifies data types, units, and constraints for its attributes and commands. + +### Data Types +| Type | Example | Description | +|------|---------|-------------| +| string | `"locked"` | May have enum or pattern constraints | +| integer | `5` | Whole number, may have min/max | +| number | `5.5` | Fractional values allowed | +| boolean | `true` | true or false | +| object | `{x: 12}` | Map of name-value pairs | +| array | `["heat","cool"]` | List of single type | + +### Common Capabilities +- `switch` - on/off control +- `switchLevel` - dimming (0-100) +- `temperatureMeasurement` - temperature reading +- `battery` - battery percentage +- `contactSensor` - open/closed +- `motionSensor` - active/inactive +- `lock` - locked/unlocked +- `thermostatMode`, `thermostatHeatingSetpoint`, `thermostatCoolingSetpoint` +- `colorTemperature`, `colorControl` +- `refresh` - request device state update +- `firmwareUpdate` - OTA firmware management +- `healthCheck` - device connectivity monitoring + +Full reference: https://developer.smartthings.com/docs/devices/capabilities/capabilities-reference + +## 2. Standard vs Custom Capabilities + +### Standard Capabilities +Standard capabilities live under the `smartthings` namespace but are referenced without a namespace prefix: +```yaml +- id: switch + version: 1 +- id: temperatureMeasurement + version: 1 +``` + +### Custom Capabilities +Custom capabilities use the format `namespace.capabilityName`: +```yaml +- id: perfectlife6617.customGarageDoor + version: 1 +``` + +A namespace is auto-generated per developer account (e.g., `perfectlife6617`). Custom capabilities are created via the SmartThings CLI: +``` +smartthings capabilities:create -i capability.json +``` + +Custom capabilities require a Capability Presentation to render properly in the app. + +## 3. Device Profile YAML Format + +Device profiles define which capabilities a device exposes, organized into components. They live in `profiles/` directories within driver packages. + +### Basic Profile Example (from `zwave-lock`) +```yaml +name: base-lock +components: +- id: main + capabilities: + - id: lock + version: 1 + - id: lockCodes + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartLock +``` + +### Multi-Component Profile (from `zigbee-fan`) +```yaml +name: fan-light +components: + - id: main + label: Fan + capabilities: + - id: switch + version: 1 + - id: fanSpeed + version: 1 + config: + values: + - key: "fanSpeed.value" + range: [0, 3] + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan + - id: light + label: Light + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [0, 100] + - id: refresh + version: 1 + categories: + - name: Light +``` + +### Profile with Embedded Config and Preferences (from `zigbee-contact`) +```yaml +name: multi-sensor +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: threeAxis + version: 1 + - id: accelerationSensor + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MultiFunctionalSensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: certifiedpreferences.garageSensor + explicit: true +``` + +### Key Profile Rules +- Must have at least one component; the primary is always `id: main` +- Use multiple components when the same capability is needed more than once (e.g., multi-gang switch) +- Each component needs at least one capability +- `categories` determines the device icon in the app (e.g., `SmartLock`, `Fan`, `Light`, `Thermostat`, `MultiFunctionalSensor`) +- `version: 1` is always used (only version supported) + +## 4. Embedded Device Configurations + +Embedded device configs let you customize the SmartThings app UI directly in the profile YAML, without creating a separate Device Presentation. Only supported by Edge Drivers. + +### Range Constraint +```yaml +- id: colorTemperature + config: + values: + - key: "colorTemperature.value" + range: [2600, 6200] +``` + +### Enabled Values (filter enum options) +```yaml +- id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - heating + - cooling + - fan only + - idle +``` + +### Separate Attribute vs Command Values +```yaml +- id: thermostatMode + config: + values: + - key: thermostatMode.value + enabledValues: + - off + - heat + - eco + - key: setThermostatMode + enabledValues: + - off + - heat +``` + +### Enum Commands +```yaml +- id: alarm + config: + values: + - key: alarm.value + enabledValues: + - off + - siren + - key: "{{enumCommands}}" + enabledValues: + - off + - siren +``` + +When you package the driver, the platform auto-generates a Device Presentation from these configs. + +## 5. Preferences + +Preferences let users configure device behavior from Settings in the SmartThings app. + +### Two Types + +**Explicit (shared/reusable):** Defined externally, referenced by ID in the profile: +```yaml +preferences: + - preferenceId: tempOffset + explicit: true +``` + +Standard explicit preferences include: `tempOffset`, `humidityOffset`, `motionSensitivity`, `reportingInterval`, `reverse`, `presetPosition`, `username`, `password`. + +`tempOffset` and `humidityOffset` are automatically applied by the platform to attribute values - no driver code needed. + +**Embedded (inline in profile):** Defined directly in the profile YAML: +```yaml +preferences: + - title: "IP Address" + name: ipAddress + description: "IP address of the Pi-Hole" + required: true + preferenceType: string + definition: + minLength: 7 + maxLength: 15 + stringType: text + default: localhost +``` + +### Preference Types +| Type | Definition Fields | +|------|------------------| +| boolean | `default` | +| integer | `minimum`, `maximum`, `default` | +| number | `minimum`, `maximum`, `default` | +| string | `stringType` (text/paragraph/password), `minLength`, `maxLength`, `default` | +| enumeration | `options` (key-value map), `default` (must match a key) | + +### Accessing Preferences in Lua + +Query current value: +```lua +local offset = device.preferences.tempOffset +local level = command.args.level + device.preferences.levelOffset +``` + +Handle preference changes via `infoChanged` lifecycle: +```lua +local function device_info_changed(driver, device, event, args) + if args.old_st_store.preferences.sensitivityLevel ~= device.preferences.sensitivityLevel then + device:send() + end +end +``` + +For sleepy Z-Wave devices, use `device:set_update_preferences_fn(fn)` which fires on wakeup. + +## 6. config.yml + +The `config.yml` file is the driver package manifest. It lives at the root of each driver directory. + +```yaml +name: 'Zigbee Thermostat' +defaultProfile: 'thermostat-battery-powerSource' +packageKey: 'zigbee-thermostat' +permissions: + zigbee: {} +description: "SmartThings driver for Zigbee thermostat devices" +vendorSupportInformation: "https://support.smartthings.com" +``` + +### Fields +| Field | Description | +|-------|-------------| +| `name` | Human-readable driver name | +| `packageKey` | Unique package identifier | +| `permissions` | Protocol access: `zigbee: {}`, `zwave: {}`, `lan: {}`, `matter: {}` | +| `description` | Driver description | +| `defaultProfile` | Profile name used when no fingerprint match specifies one | +| `vendorSupportInformation` | Support URL | + +## 7. Fingerprints + +Fingerprints map physical devices to profiles. They live in `fingerprints.yml` at the driver root. + +### Zigbee Fingerprints +```yaml +zigbeeManufacturer: + - id: "LUMI/lumi.motion.ac02" + deviceLabel: Aqara Motion Sensor P1 + manufacturer: LUMI + model: lumi.motion.ac02 + deviceProfileName: motion-illuminance-battery-aqara + - id: "SmartThings/motionv5" + deviceLabel: Motion Sensor + manufacturer: SmartThings + model: motionv5 + deviceProfileName: motion-temp-battery + +zigbeeGeneric: + - id: kickstarter/motion/1 + deviceLabel: SmartThings Motion Sensor + zigbeeProfiles: + - 0xFC01 + deviceIdentifiers: + - 0x013A + deviceProfileName: smartsense-motion +``` + +### Key Fingerprint Fields +| Field | Description | +|-------|-------------| +| `id` | Unique identifier for the fingerprint | +| `deviceLabel` | Default label shown to users | +| `manufacturer` | Device manufacturer string | +| `model` | Device model string | +| `deviceProfileName` | Which profile from `profiles/` to use | +| `zigbeeProfiles` | (zigbeeGeneric) Zigbee profile IDs | +| `deviceIdentifiers` | (zigbeeGeneric) Zigbee device type IDs | + +Z-Wave fingerprints use `manufacturerId`, `productType`, and `productId` instead. + +## 8. Relationship: config.yml + Profiles + Fingerprints + +``` +driver/ +├── config.yml # Package manifest, declares defaultProfile +├── fingerprints.yml # Maps hardware → profile by deviceProfileName +├── profiles/ +│ ├── basic-device.yml # Profile A +│ └── advanced-device.yml # Profile B +└── src/ + └── init.lua # Driver logic +``` + +Flow: +1. A device joins the hub +2. The platform matches it against `fingerprints.yml` entries +3. The matched fingerprint's `deviceProfileName` selects which profile to use +4. If no fingerprint matches, `defaultProfile` from `config.yml` is used +5. The profile defines capabilities, components, categories, and preferences +6. Embedded `config` in the profile customizes the app UI +7. The driver's Lua code handles capability commands and emits attribute events diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..cf17ce46b3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,52 @@ + You are working in the SmartThings Edge Drivers repository. Drivers are written in **Lua 5.3** and + run on SmartThings hubs. They translate Zigbee, Z-Wave, Matter, and LAN protocol messages into + SmartThings capability commands and events. + + For full context, read `AGENTS.md` at the repository root. It covers driver structure, lifecycle, + profiles, and available skills for deeper domain knowledge. + + ## Standard Commands + + ```bash + # Run tests + python3 tools/run_driver_tests.py -vv -f + + # Lint + luacheck --config .github/workflows/.luacheckrc + + # Deploy + smartthings edge:drivers:package --hub= --channel= +``` + +## Rules + +Always: + + - Run tests before considering a change complete + - Run luacheck on modified Lua files + - Use existing standard capabilities before creating custom ones + - Follow existing driver structure patterns + +Ask before: + + - Modifying device profile YAML files (changes affect production devices) + - Adding new custom capabilities + - Changing config.yml permissions + +Never: + + - Commit hardcoded API keys or tokens + - Skip tests for driver changes + - Use Lua features beyond 5.3 + +## Skills + +Load these files for deeper knowledge when working in each area: + +| Task | Skill file | +|------|-----------| +| Driver lifecycle, dispatch, default handlers | .agents/skills/understanding-lua-libraries/SKILL.md | +| Profiles, capabilities, preferences, fingerprints | .agents/skills/understanding-profiles/SKILL.md | +| Writing and running tests | .agents/skills/testing-edge-drivers/SKILL.md | +| Luacheck / code style | .agents/skills/linting-and-style/SKILL.md | +| Environment setup, deploying, sharing via channels | .agents/skills/dev-workflow/SKILL.md | diff --git a/.github/scripts/check_duplicates.py b/.github/scripts/check_duplicates.py index b4bf01649d..e335f6083a 100644 --- a/.github/scripts/check_duplicates.py +++ b/.github/scripts/check_duplicates.py @@ -1,10 +1,10 @@ -import csv import os import yaml from pathlib import Path cwd = os.getcwd() duplicate_pairs = [] +deleted_profiles = [] def compare_component_capabilities_unordered(comp1, comp2): for cap1 in comp1["capabilities"]: @@ -100,44 +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) - for current_profile in os.listdir("./"): - new_profile = file_basename - - # 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: @@ -145,7 +150,12 @@ def compare_components(prof1, prof2): for duplicate in duplicate_pairs: f.write("%s == %s\n" % (duplicate[0], duplicate [1])) else: - f.write("Duplicate profile check: Passed - no duplicate profiles detected.") + f.write("Duplicate profile check: Passed - no duplicate profiles detected.\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-comment-body.md", "r") as f: print("\n" + f.read()) 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/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/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/jenkins-driver-tests.yml b/.github/workflows/jenkins-driver-tests.yml new file mode 100644 index 0000000000..6af62c080e --- /dev/null +++ b/.github/workflows/jenkins-driver-tests.yml @@ -0,0 +1,46 @@ +name: Run Jenkins driver tests +on: + pull_request_target: + paths: + - 'drivers/**' + +jobs: + trigger-driver-test: + strategy: + matrix: + version: + [57, 58, 59, 60, 61, dev] + + runs-on: ubuntu-latest + steps: + + - name: Create Commit Status (Pending) + id: status + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd + with: + script: | + let response = await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.pull_request.head.sha, + state: 'pending', + description: 'Jenkins job triggered...', + context: 'Driver Tests (${{ matrix.version }})' + }); + core.setOutput('status_url', response.data.url); + - name: Trigger Jenkins Generic Webhook + env: + JENKINS_WEBHOOK_TOKEN: ${{ secrets.JENKINS_WEBHOOK_TOKEN }} + JENKINS_WEBHOOK_URL: ${{ secrets.JENKINS_WEBHOOK_URL }} + STATUS_URL: ${{ steps.status.outputs.status_url }} + run: | + set +x + curl -s -o /dev/null -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${JENKINS_WEBHOOK_TOKEN}" \ + -d "{\"status_url\": \"$STATUS_URL\", + \"version\": ${{ matrix.version }}, + \"commit\": ${{ github.event.pull_request.head.sha }}, + \"pr_id\": ${{ github.event.number }} }" \ + "${JENKINS_WEBHOOK_URL}" + set -x 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 e58afd5178..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 @@ -39,51 +41,10 @@ jobs: id: cache_key run: echo "CACHE_KEY=${{ steps.lib-version.outputs.url }}-v1" >> $GITHUB_OUTPUT - get-dev-artifact: - runs-on: ubuntu-latest - outputs: - cache_key: ${{ steps.cache_key.outputs.CACHE_KEY }} - if: ${{ contains(join(github.event.pull_request.labels.*.name), 'release-') && github.event.pull_request.head.repo.fork != 'true' }} - steps: - - name: Get the version from the label - id: label-version - run: | - echo "${{ join(github.event.pull_request.labels.*.name) }}" | grep -oP "release-\d+.\d+" | xargs > out - echo "LIBRARY_VERSION=$(cat out)" >> $GITHUB_OUTPUT - mkdir /home/runner/work/lua_libs - - name: Find latest artifact - id: latest - env: - ARTIFACTORY_URL: ${{ format('https://smartthings.jfrog.io/artifactory/edge-driver-libs/{0}/', steps.label-version.outputs.LIBRARY_VERSION) }} - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - run: | - wget --user=$ARTIFACTORY_USERNAME --password=$ARTIFACTORY_PASSWORD $ARTIFACTORY_URL -q -O - | grep '.zip' | awk -F' ' '{print $3"-"$4"\t"$2}' | sort -t - -k3n -k2M -k1n -k4n | tail -1 | grep -o 'lua_libs_[a-z0-9_]*.zip' | head -1 > out - echo "ZIP_FILE=$(cat out)" >> $GITHUB_OUTPUT - - name: Try to retrieve cache - id: cached-libs - uses: actions/cache@v3 - with: - path: '/home/runner/work/lua_libs' - key: ${{ steps.latest.outputs.ZIP_FILE }}-v1 - - name: Download and unpack specified version - if: steps.cached-libs.outputs.cache-hit != 'true' - env: - ARTIFACTORY_URL: ${{ format('https://smartthings.jfrog.io/artifactory/edge-driver-libs/{0}/{1}', steps.label-version.outputs.LIBRARY_VERSION, steps.latest.outputs.ZIP_FILE) }} - ARTIFACTORY_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} - ARTIFACTORY_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} - working-directory: '/home/runner/work/lua_libs' - run: | - wget --user=$ARTIFACTORY_USERNAME --password=$ARTIFACTORY_PASSWORD $ARTIFACTORY_URL -O lua_libs.zip - unzip lua_libs.zip - - name: Set output - id: cache_key - run: echo "CACHE_KEY=${{ steps.latest.outputs.ZIP_FILE }}-v1" >> $GITHUB_OUTPUT - run-driver-tests: runs-on: ubuntu-latest needs: - [ get-latest-release-artifact, get-dev-artifact ] + [ get-latest-release-artifact ] if: ${{ always() && contains(needs.*.result, 'success') && !contains(needs.*.result, 'failure') }} steps: - name: Set cache key @@ -132,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/.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/.gitignore b/.gitignore index c5bbdc6f99..72da442d81 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,7 @@ lua_libs-api_* *.out tools/test_output/* tools/coverage_output/* +tools/coverage_output_html/ +tools/__pycache__/ .DS_Store .venv/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..ecb9478f81 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# SmartThings Edge Drivers — Agent Instructions + +You are an expert Lua 5.3 engineer and SmartThings Edge Driver maintainer. This repository contains production Edge Drivers for the SmartThings platform, spanning Zigbee, Z-Wave, Matter, and LAN protocols. + +Lua drivers translate between device protocol messages and SmartThings capability commands/events to support hub connected devices on the platform. + +## Repository Structure + +``` +drivers/ # Edge Drivers organized by vendor (SmartThings/, Aqara/, etc.) + // + config.yml # Driver metadata, permissions, capabilities, preferences + profiles/ # Device profile YAML definitions + fingerprints.yml # Device identification fingerprints (optional, can be in Lua) + src/ + init.lua # Driver entry point + sub_drivers/ # Protocol/device-specific sub-drivers (optional) + test/ # Integration tests +lua_libs/ # SmartThings Lua runtime libraries (from latest GitHub release) +tools/ # Test runners, deploy scripts, utilities +.github/workflows/ # CI: tests, luacheck, packaging +``` + +## Standard Commands + +### Run Tests +```bash +python3 tools/run_driver_tests.py -vv -f +``` +The filter matches against driver directory/file names. Load the `testing-edge-drivers` skill for details. + +### Lint +```bash +luacheck --config .github/workflows/.luacheckrc +``` +Load the `linting-and-style` skill for configuration details and common fixes. + +### Deploy a Driver +```bash +smartthings edge:drivers:package --hub= --channel= +``` +Load the `dev-workflow` skill for channel setup and sharing instructions. + +## Driver Anatomy + +Drivers live under `drivers///`. The canonical layout is: + +``` +drivers/// + config.yml # Driver metadata: name, packageKey, permissions, description + fingerprints.yml # Device matching rules (Zigbee, Z-Wave, Matter only) + search-parameters.yml # SSDP/mDNS discovery hints (LAN drivers only) + profiles/ + .yml # One file per device profile + src/ + init.lua # Driver entry point; creates template and calls :run() + sub_drivers.lua # Optional: list of sub-driver require paths + / + init.lua # Sub-driver table: NAME, can_handle, handlers + can_handle.lua # Optional: separated device-matching function + fingerprints.lua # Optional: Lua-side fingerprint list for can_handle +``` + +Load the `understanding-lua-libraries` skill for detailed information on the driver framework. + +### Fingerprints (`fingerprints.yml`) + +Fingerprints tell the platform which driver to assign to a newly-joined device. +When a device is discovered, the hub reads its identifying properties and sends +them to the SmartThings cloud, which finds the best matching fingerprint and +installs the corresponding driver. + +Manufacturer-specific fingerprints always win over generic ones when both match. + +LAN drivers do **not** use `fingerprints.yml`. They define a `discovery` handler in the driver +template which is called when the hub forwards discovery requests to the driver. This discovery +handler is responsible for searching for the device on the network and creating the device. + +### Device Profiles (`profiles/*.yml`) + +A profile declares the SmartThings **capabilities** a device exposes, grouped into +**components**. The `main` component is the primary one. A fingerprint's +`deviceProfileName` value must exactly match the `name` field in a profile file. + +Load the `understanding-profiles` skill for details on profiles and how they +define devices on the platform. + +## Lua Libraries (`lua_libs/`) + +The `lua_libs/` directory at the repository root is setup by the developer and not committed +to the repository. It contains the SmartThings Edge SDK: the Lua framework, protocol libraries, +test utilities, and third-party dependencies. **This directory must be present for tests to run.** +Load the `dev-workflow` skill to help with initial setup. + +Load the `understanding-lua-libraries` skill for detailed information on the lua libraries. + +--- + +## Rules + +### ✅ ALWAYS +- Run tests before considering a change complete +- Run luacheck on modified Lua files +- Use existing capabilities from the SmartThings reference before creating custom ones +- Follow the existing driver structure patterns in this repo +- Use `require` paths relative to `src/` for driver code, and `lua_libs/` for library code + +### ⚠️ ASK FIRST +- Before modifying device profile YAML files (changes affect production devices) +- Before adding new custom capabilities +- Before changing `config.yml` permissions +- Before modifying shared library code in `lua_libs/` + +### 🚫 NEVER +- Commit hardcoded API keys, tokens, or hub UUIDs +- Modify files in `lua_libs/` (these come from upstream releases) +- Skip tests for driver changes +- Use Lua features beyond 5.3 (the hub runtime is Lua 5.3) + +## Available Skills + +Load these for deeper domain knowledge: + +| Skill | When to Use | Skill file | +|-------|-------------|------------| +| `understanding-profiles` | Defining or modifying capabilities, profiles, preferences, or device configurations | .agents/skills/understanding-profiles/SKILL.md | +| `understanding-lua-libraries` | Understanding the driver lifecycle, message dispatchers, default handlers, or protocol objects | .agents/skills/understanding-lua-libraries/SKILL.md | +| `testing-edge-drivers` | Running and writing driver tests using the integration test framework | .agents/skills/testing-edge-drivers/SKILL.md | +| `linting-and-style` | Running luacheck or fixing style issues | .agents/skills/linting-and-style/SKILL.md | +| `dev-workflow` | Setting up the dev environment, deploying drivers, or sharing via channels | .agents/skills/dev-workflow/SKILL.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 diff --git a/Jenkinsfile b/Jenkinsfile index 425da986db..cedacdf64d 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-cn' + } + 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/ABB/insite-scu200/config.yml b/drivers/ABB/insite-scu200/config.yml new file mode 100644 index 0000000000..2cda5384d4 --- /dev/null +++ b/drivers/ABB/insite-scu200/config.yml @@ -0,0 +1,7 @@ +name: 'SCU200 InSite Energy Management System' +packageKey: 'ABB.SCU200' +description: "SmartThings driver for SCU200 InSite Energy Management System" +vendorSupportInformation: "https://support.smartthings.com" +permissions: + lan: {} + discovery: {} \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/auxiliary-contact.yml b/drivers/ABB/insite-scu200/profiles/auxiliary-contact.yml new file mode 100644 index 0000000000..92c4a2fcb6 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/auxiliary-contact.yml @@ -0,0 +1,34 @@ +name: abb.scu200.auxiliary-contact.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +deviceConfig: + dashboard: + states: + - component: main + capability: switch + version: 1 + actions: [] + detailView: + - component: main + capability: switch + version: 1 + visibleCondition: + capability: switch + version: 1 + component: main + value: switch.value + operator: ONE_OF + operand: '[""]' + automation: + conditions: + - component: main + capability: switch + version: 1 + actions: [] \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/bridge.yml b/drivers/ABB/insite-scu200/profiles/bridge.yml new file mode 100644 index 0000000000..d45ebc4943 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/bridge.yml @@ -0,0 +1,8 @@ +name: abb.scu200.bridge.v1 +components: +- id: main + capabilities: + - id: refresh + version: 1 + categories: + - name: Bridges diff --git a/drivers/ABB/insite-scu200/profiles/current-sensor-consumption.yml b/drivers/ABB/insite-scu200/profiles/current-sensor-consumption.yml new file mode 100644 index 0000000000..5843dae90f --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/current-sensor-consumption.yml @@ -0,0 +1,16 @@ +name: abb.scu200.current-sensor-consumption.v1 +components: +- id: main + capabilities: + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/current-sensor-production.yml b/drivers/ABB/insite-scu200/profiles/current-sensor-production.yml new file mode 100644 index 0000000000..ec4e12aeae --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/current-sensor-production.yml @@ -0,0 +1,16 @@ +name: abb.scu200.current-sensor-production.v1 +components: +- id: main + capabilities: + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/energy-meter.yml b/drivers/ABB/insite-scu200/profiles/energy-meter.yml new file mode 100644 index 0000000000..37fab87dbe --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/energy-meter.yml @@ -0,0 +1,44 @@ +name: abb.scu200.energy-meter.v1 +components: +- id: main + capabilities: + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: powerMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter +- id: consumptionMeter + label: "From Grid" + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter +- id: productionMeter + label: "To Grid" + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter +- id: surplus + capabilities: + - id: powerConsumptionReport + version: 1 + - id: energyMeter + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/gas-meter.yml b/drivers/ABB/insite-scu200/profiles/gas-meter.yml new file mode 100644 index 0000000000..cc3297eb05 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/gas-meter.yml @@ -0,0 +1,10 @@ +name: abb.scu200.gas-meter.v1 +components: +- id: main + capabilities: + - id: gasMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Others \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/output-module.yml b/drivers/ABB/insite-scu200/profiles/output-module.yml new file mode 100644 index 0000000000..d6e0395da8 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/output-module.yml @@ -0,0 +1,10 @@ +name: abb.scu200.output-module.v1 +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch diff --git a/drivers/ABB/insite-scu200/profiles/usb-energy-meter.yml b/drivers/ABB/insite-scu200/profiles/usb-energy-meter.yml new file mode 100644 index 0000000000..8eb09ce365 --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/usb-energy-meter.yml @@ -0,0 +1,14 @@ +name: abb.scu200.usb-energy-meter.v1 +components: +- id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: CurbPowerMeter \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/profiles/water-meter.yml b/drivers/ABB/insite-scu200/profiles/water-meter.yml new file mode 100644 index 0000000000..bf5f50068d --- /dev/null +++ b/drivers/ABB/insite-scu200/profiles/water-meter.yml @@ -0,0 +1,10 @@ +name: abb.scu200.water-meter.v1 +components: +- id: main + capabilities: + - id: waterMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Others \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/search-parameters.yaml b/drivers/ABB/insite-scu200/search-parameters.yaml new file mode 100644 index 0000000000..5cfaf45c83 --- /dev/null +++ b/drivers/ABB/insite-scu200/search-parameters.yaml @@ -0,0 +1,2 @@ +ssdp: + - searchTerm: urn:ABB:device:SCU200:1 \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/abb/api.lua b/drivers/ABB/insite-scu200/src/abb/api.lua new file mode 100644 index 0000000000..6524d86567 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/abb/api.lua @@ -0,0 +1,145 @@ +local log = require("log") +local st_utils = require "st.utils" +local json = require "st.json" + +-- Local imports +local config = require("config") +local utils = require("utils") +local RestClient = require "lunchbox.rest" + +-- API for the ABB SCU200 Bridge +local api = {} +api.__index = api + +local SSL_CONFIG = { + mode = "client", + protocol = "any", + verify = "none", + options = "all" +} + +local ADDITIONAL_HEADERS = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", +} + +-- Method for getting the base URL +local function get_base_url(bridge_ip) + return "https://" .. bridge_ip .. ":" .. config.REST_API_PORT +end + +-- Method for processing the REST response +local function process_rest_response(response, err, partial) + if err ~= nil then + return response, err, nil + elseif response ~= nil then + local status, decoded_json = pcall(json.decode, response:get_body()) + + if status and response.status == 200 then + log.debug("process_rest_response(): Response = " .. response.status .. " " .. response:get_body()) + + return decoded_json, nil, response.status + elseif status then + log.error("process_rest_response(): Response error = " .. response.status) + + return nil, "response status is not 200 OK", response.status + else + log.error("process_rest_response(): Failed to decode data") + + return nil, "failed to decode data", nil + end + else + return nil, "no response or error received", nil + end +end + +-- Method for creating a retry function +local function retry_fn(retry_attempts) + local count = 0 + + return function() + count = count + 1 + return count < retry_attempts + end +end + +-- Method for performing a GET request +local function do_get(api_instance, path) + log.debug("do_get(): Sending GET request to " .. path) + + return process_rest_response(api_instance.client:get(path, api_instance.headers, retry_fn(5))) +end + +-- Method for performing a POST request +local function do_post(api_instance, path, payload) + log.debug("do_post(): Sending POST request to " .. path .. " with payload " .. json.encode(payload)) + + return process_rest_response(api_instance.client:post(path, payload, api_instance.headers, retry_fn(5))) +end + +-- Method for creating a labeled socket builder +function api.labeled_socket_builder(label) + local socket_builder = utils.labeled_socket_builder(label, SSL_CONFIG) + + return socket_builder +end + +-- Method for creating a new bridge manager +function api.new_bridge_manager(bridge_ip, bridge_dni) + local base_url = get_base_url(bridge_ip) + local socket_builder = api.labeled_socket_builder(bridge_dni) + + return setmetatable( + { + headers = st_utils.deep_copy(ADDITIONAL_HEADERS), + client = RestClient.new(base_url, socket_builder), + base_url = base_url + }, + api + ) +end + +-- Method for getting the thing infos +function api.get_thing_infos(bridge_ip, bridge_dni) + local socket_builder = api.labeled_socket_builder(bridge_dni .. " (thing infos)") + local response, error, status = process_rest_response(RestClient.one_shot_get(get_base_url(bridge_ip) .. "/devices", ADDITIONAL_HEADERS, socket_builder)) + + if not error and status == 200 then + return response + else + log.error("api.get_thing_infos(): Failed to get thing infos, error = " .. error) + return nil + end +end + +-- Method for getting the bridge info +function api.get_bridge_info(bridge_ip, bridge_dni) + local socket_builder = api.labeled_socket_builder(bridge_dni .. " (bridge info)") + local response, error, status = process_rest_response(RestClient.one_shot_get(get_base_url(bridge_ip) .. "/bridge", ADDITIONAL_HEADERS, socket_builder)) + + if not error and status == 200 then + return response + else + log.error("api.get_bridge_info(): Failed to get thing infos, error = " .. error) + return nil + end +end + +-- API methods +function api:get_devices() + return do_get(self, "/devices") +end + +function api:get_device_by_id(id) + return do_get(self, string.format("/devices/%s", id)) +end + +function api:post_device_by_id(id, payload) + return do_post(self, string.format("/devices/%s/control", id), payload) +end + +function api:get_sse_url() + return self.base_url .. "/events" +end + +return api \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/abb/device_manager.lua b/drivers/ABB/insite-scu200/src/abb/device_manager.lua new file mode 100644 index 0000000000..a9de6c0811 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/abb/device_manager.lua @@ -0,0 +1,115 @@ +local log = require("log") + +-- Local imports +local utils = require("utils") +local fields = require("fields") +local api = require("abb.api") +local device_refresher = require("abb.device_refresher") + +-- Device manager methods +local device_manager = {} + +-- Method for checking if connection is valid +function device_manager.is_valid_connection(driver, device, conn_info) + local dni = utils.get_dni_from_device(device) + + if not conn_info then + log.error("device_manager.is_valid_connection(): Failed to find conn_info, dni = " .. dni) + return false + end + + local bridge_ip = utils.get_device_ip_address(device) + local thing_infos = api.get_thing_infos(bridge_ip, dni) + + if thing_infos and thing_infos.devices then + return true + else + log.error("device_manager.is_valid_connection(): Failed to get thing infos, dni = " .. dni) + return false + end +end + +-- Method for getting bridge connection info +function device_manager.get_bridge_connection_info(driver, bridge_dni, bridge_ip) + local bridge_conn_info = api.new_bridge_manager(bridge_ip, bridge_dni) + + if bridge_conn_info == nil then + log.error("device_manager.get_bridge_connection_info(): No bridge connection info") + end + + return bridge_conn_info +end + +-- Method for handling JSON status +function device_manager.handle_device_json(driver, device, device_json) + local dni = utils.get_dni_from_device(device) + if dni == nil then + log.error("device_manager.handle_device_json(): dni is nil, the device has been probably deleted") + return + end + + if not device_json then + log.error("device_manager.handle_device_json(): device_json is nil, dni = " .. dni) + return + end + + log.debug("device_manager.handle_device_json(): dni: " .. dni .. " device_json = " .. utils.dump(device_json)) + + local status = device_json.status + if status ~= nil then + if status == "offline" then + log.info("device_manager.handle_device_json(): status is offline, dni = " .. dni) + + device:offline() + return + elseif status == "online" then + device:online() + end + end + + local values = device_json.values + if values == nil then + log.error("device_manager.handle_device_json(): values is nil, dni = " .. dni) + return + end + + device_refresher.refresh_device(driver, device, values) +end + +-- Method for refreshing device +function device_manager.refresh(driver, device) + local dni = utils.get_dni_from_device(device) + local communication_device = device:get_parent_device() or device + local conn_info = communication_device:get_field(fields.CONN_INFO) + + if not conn_info then + log.warn("device_manager.refresh(): Failed to find conn_info, dni = " .. dni) + return + end + + local response, err, status = conn_info:get_device_by_id(dni) + + if err or status ~= 200 then + status = status or "nil" + log.error("device_manager.refresh(): Failed to get device by id, dni = " .. dni .. ", err = " .. err .. ", status = " .. status) + + device:offline() + + return + end + + device_manager.handle_device_json(driver, device, response) +end + +-- Method for monitoring the connection of the bridge devices +function device_manager.bridge_monitor(driver, device, bridge_info) + local child_devices = device:get_child_list() + + for _, thing_device in ipairs(child_devices) do + device.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + device_manager.refresh(driver, thing_device) + end) + end +end + +return device_manager \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/abb/device_refresher.lua b/drivers/ABB/insite-scu200/src/abb/device_refresher.lua new file mode 100644 index 0000000000..628c5b4201 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/abb/device_refresher.lua @@ -0,0 +1,382 @@ +local log = require("log") +local caps = require('st.capabilities') + +-- Local imports +local utils = require("utils") +local config = require("config") +local fields = require('fields') + +-- Controller for refreshing device data +local device_refresher = {} + +local function refresh_current_sensor(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_current_sensor(): Refreshing data of Current Sensor, dni = " .. dni) + + -- Refresh Current Measurement + local current = values.current + + if current ~= nil then + log.trace("refresh_current_sensor(): Refreshing Current Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.currentMeasurement.current({value=current, unit="A"})) + end + + -- Refresh Active Power + local activePower = values.activePower + + if activePower ~= nil then + log.trace("refresh_current_sensor(): Refreshing Active Power, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerMeter.power({value=activePower, unit="W"})) + end + + -- Refresh Active Energy + local activeEnergy = values.activeEnergy + + if activeEnergy ~= nil then + -- Refresh Active Energy + log.trace("refresh_current_sensor(): Refreshing Active Energy, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.energyMeter.energy({value=activeEnergy, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy values + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy consumption/production periodically + local current_consumption_production_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta consumption/production energy + local delta_consumption_production_report = 0.0 + if current_consumption_production_report ~= nil then + delta_consumption_production_report = math.max((activeEnergy * 1000) - current_consumption_production_report.energy, 0.0) + end + + -- Refresh Power Energy Report + log.trace("refresh_current_sensor(): Refreshing Energy Report, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergy * 1000, deltaEnergy=delta_consumption_production_report})) + + -- Save date of the last energy report + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_current_sensor(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +local function refresh_energy_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_energy_meter(): Refreshing data of Energy Meter, dni = " .. dni) + + -- Refresh Voltage Measurement + local voltage = values.voltage + + if voltage ~= nil then + log.trace("refresh_energy_meter(): Refreshing Voltage Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.voltageMeasurement.voltage({value=voltage, unit="V"})) + end + + -- Refresh Current Measurement + local current = values.current + + if current ~= nil then + log.trace("refresh_energy_meter(): Refreshing Current Measurement, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.currentMeasurement.current({value=current, unit="A"})) + end + + -- Refresh Active Power + local activePower = values.activePowerTotal + + if activePower ~= nil then + log.trace("refresh_energy_meter(): Refreshing Active Power, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerMeter.power({value=activePower, unit="W"})) + end + + -- Refresh Active Energy Net, Import & Export Total + local activeEnergyNetTotal = values.activeEnergyNetTotal + local activeEnergyImportTotal = values.activeEnergyImportTotal + local activeEnergyExportTotal = values.activeEnergyExportTotal + + if activeEnergyNetTotal == nil then + if activeEnergyImportTotal ~= nil and activeEnergyExportTotal ~= nil then + -- If only Import and Export Total are available, calculate Net Total + activeEnergyNetTotal = activeEnergyImportTotal - activeEnergyExportTotal + end + end + + local activeEnergyNetPositive = math.max(activeEnergyNetTotal, 0.0) + local activeEnergyNetNegative = math.min(activeEnergyNetTotal, 0.0) * -1 -- Convert to positive value + + if activeEnergyNetTotal ~= nil and activeEnergyImportTotal ~= nil and activeEnergyExportTotal ~= nil then + -- Refresh Active Energy Net Positive + log.trace("refresh_energy_meter(): Refreshing Active Energy Net Positive, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.energyMeter.energy({value=activeEnergyNetPositive, unit="kWh"})) + + -- Refresh Active Energy Import Total + log.trace("refresh_energy_meter(): Refreshing Active Energy Import Total, dni = " .. dni) + device.profile.components["consumptionMeter"]:emit_event(caps.energyMeter.energy({value=activeEnergyImportTotal, unit="kWh"})) + + -- Refresh Active Energy Export Total + log.trace("refresh_energy_meter(): Refreshing Active Energy Export Total, dni = " .. dni) + device.profile.components["productionMeter"]:emit_event(caps.energyMeter.energy({value=activeEnergyExportTotal, unit="kWh"})) + + -- Refresh Active Energy Net Negative + log.trace("refresh_energy_meter(): Refreshing Active Energy Net Negative, dni = " .. dni) + device.profile.components["surplus"]:emit_event(caps.energyMeter.energy({value=activeEnergyNetNegative, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy net, consumption and production + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy net, consumption and production periodically + local current_net_positive_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_consumption_report = device:get_latest_state("consumptionMeter", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_production_report = device:get_latest_state("productionMeter", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + local current_net_negative_report = device:get_latest_state("surplus", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta net, consumption and production energy + local delta_net_positive_report = 0.0 + if current_net_positive_report ~= nil then + delta_net_positive_report = math.max((activeEnergyNetPositive * 1000) - current_net_positive_report.energy, 0.0) + end + + local delta_consumption_energy = 0.0 + if current_consumption_report ~= nil then + delta_consumption_energy = math.max((activeEnergyImportTotal * 1000) - current_consumption_report.energy, 0.0) + end + + local delta_production_energy = 0.0 + if current_production_report ~= nil then + delta_production_energy = math.max((activeEnergyExportTotal * 1000) - current_production_report.energy, 0.0) + end + + local delta_net_negative_report = 0.0 + if current_net_negative_report ~= nil then + delta_net_negative_report = math.max((activeEnergyNetNegative * 1000) - current_net_negative_report.energy, 0.0) + end + + -- Refresh Power Net Positive, Consumption, Production & Net Negative Report + log.trace("refresh_energy_meter(): Refreshing Power Net Positive, Consumption, Production & Net Negative Report, dni = " .. dni) + device.profile.components["main"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyNetPositive * 1000, deltaEnergy=delta_net_positive_report})) + device.profile.components["consumptionMeter"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyImportTotal * 1000, deltaEnergy=delta_consumption_energy})) + device.profile.components["productionMeter"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyExportTotal * 1000, deltaEnergy=delta_production_energy})) + device.profile.components["surplus"]:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyNetNegative * 1000, deltaEnergy=delta_net_negative_report})) + + -- Save date of the last consumption + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_energy_meter(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +local function refresh_auxiliary_contact(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_auxiliary_contact(): Refreshing data of Auxiliary Contact, dni = " .. dni) + + -- Refresh Contact Sensor + local isClosed = values.isClosed + + if isClosed ~= nil then + log.trace("refresh_auxiliary_contact(): Refreshing Switch, dni = " .. dni) + + if isClosed == 1 then + isClosed = true + else + isClosed = false + end + + if isClosed then + device:emit_event(caps.switch.switch.on()) + else + device:emit_event(caps.switch.switch.off()) + end + end + + return true +end + +local function refresh_output_module(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_output_module(): Refreshing data of Output Module, dni = " .. dni) + + -- Refresh Switch + local isClosed = values.isClosed + log.trace("refresh_output_module(): Refreshing Switch, dni = " .. dni) + + if isClosed == 1 then + isClosed = true + else + isClosed = false + end + + if isClosed then + device:emit_event(caps.switch.switch.on()) + else + device:emit_event(caps.switch.switch.off()) + end + + return true +end + +local function refresh_water_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_water_meter(): Refreshing data of Water Meter, dni = " .. dni) + + local unit = values.unit + if unit == nil then + log.error("refresh_water_meter(): The unit of the water meter is not set, dni = " .. dni) + return false + end + + -- Refresh Water Meter: last hour + local lastHourFlow = values.lastHourFlow + + if lastHourFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last hour, dni = " .. dni) + device:emit_event(caps.waterMeter.lastHour({value=lastHourFlow, unit=unit})) + end + + -- Refresh Water Meter: last 24 hours + local lastTwentyFourHoursFlow = values.lastTwentyFourHoursFlow + + if lastTwentyFourHoursFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last 24 hours, dni = " .. dni) + device:emit_event(caps.waterMeter.lastTwentyFourHours({value=lastTwentyFourHoursFlow, unit=unit})) + end + + -- Refresh Water Meter: last 7 days + local lastSevenDaysFlow = values.lastSevenDaysFlow + + if lastSevenDaysFlow ~= nil then + log.trace("refresh_water_meter(): Refreshing Water Meter: last 7 days, dni = " .. dni) + device:emit_event(caps.waterMeter.lastSevenDays({value=lastSevenDaysFlow, unit=unit})) + end + + return true +end + +local function refresh_gas_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_gas_meter(): Refreshing data of Gas Meter, dni = " .. dni) + + local gasMeterVolumeUnit = values.gasMeterVolumeUnit + if gasMeterVolumeUnit == nil then + log.error("refresh_gas_meter(): The unit of the gas meter is not set, dni = " .. dni) + return false + end + + -- Correct the unit if necessary + if gasMeterVolumeUnit == "m3" then + gasMeterVolumeUnit = "m^3" + end + + -- Refresh Gas Meter + local gasMeterVolume = values.gasMeterVolume + + if gasMeterVolume ~= nil then + log.trace("refresh_gas_meter(): Refreshing Gas Meter, dni = " .. dni) + device:emit_event(caps.gasMeter.gasMeterVolume({value=gasMeterVolume, unit=gasMeterVolumeUnit})) + end + + return true +end + +local function refresh_usb_energy_meter(driver, device, values) + local dni = utils.get_dni_from_device(device) + log.info("refresh_usb_energy_meter(): Refreshing data of USB Energy Meter, dni = " .. dni) + + -- Refresh Active Power Import Total + local activePowerImportTotal = values.activePowerImportTotal + + if activePowerImportTotal ~= nil then + log.trace("refresh_usb_energy_meter(): Refreshing Active Power Import Total, dni = " .. dni) + device:emit_event(caps.powerMeter.power({value=activePowerImportTotal, unit="W"})) + end + + -- Refresh Active Energy Import Total + local activeEnergyImportTotal = values.activeEnergyImportTotal + + if activeEnergyImportTotal ~= nil then + log.trace("refresh_usb_energy_meter(): Refreshing Active Energy Import Total, dni = " .. dni) + device:emit_event(caps.energyMeter.energy({value=activeEnergyImportTotal, unit="kWh"})) + + -- Verify whether the appropriate time have elapsed to report the energy consumption and production + local last_energy_report = device:get_field(fields.LAST_ENERGY_REPORT) or 0.0 + + if (os.time() - last_energy_report) >= config.EDGE_CHILD_ENERGY_REPORT_INTERVAL then -- Report the energy consumption periodically + local current_consumption_report = device:get_latest_state("main", caps.powerConsumptionReport.ID, caps.powerConsumptionReport.powerConsumption.NAME) + + -- Calculate delta consumption energy + local delta_consumption_energy = 0.0 + if current_consumption_report ~= nil then + delta_consumption_energy = math.max((activeEnergyImportTotal * 1000) - current_consumption_report.energy, 0.0) + end + + -- Refresh Power Consumption Report + log.trace("refresh_usb_energy_meter(): Refreshing Power Consumption Report, dni = " .. dni) + device:emit_event(caps.powerConsumptionReport.powerConsumption({energy=activeEnergyImportTotal * 1000, deltaEnergy=delta_consumption_energy})) + + -- Save date of the last consumption + local current_energy_report = last_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL + if (current_energy_report + config.EDGE_CHILD_ENERGY_REPORT_INTERVAL) < os.time() then + current_energy_report = os.time() + end + + device:set_field(fields.LAST_ENERGY_REPORT, current_energy_report, {persist=false}) + else + log.debug("refresh_usb_energy_meter(): " .. config.EDGE_CHILD_ENERGY_REPORT_INTERVAL .. " seconds haven't elapsed yet! Last consumption was at " .. last_energy_report .. ", dni = " .. dni) + end + end + + return true +end + +function device_refresher.refresh_device(driver, device, values) + local dni, device_type = utils.get_dni_from_device(device) + log.info("device_refresher.refresh_device(): Refreshing data of device, dni = " .. dni) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + log.debug("device_refresher.refresh_device(): Cannot refresh bridge device, dni = " .. dni) + return + end + + log.debug("device_refresher.refresh_device(): Provided values: " .. utils.dump(values)) + + local refresh_methods = { + [utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE)] = refresh_current_sensor, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = refresh_energy_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = refresh_auxiliary_contact, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = refresh_output_module, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = refresh_energy_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = refresh_water_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = refresh_gas_meter, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = refresh_usb_energy_meter + } + + local device_model = utils.get_device_model(device) + if device_model == nil then + log.error("device_refresher.refresh_device(): No device model found for device, dni = " .. dni) + return + end + + local refresh_method = refresh_methods[device_model] + if refresh_method == nil then + log.error("device_refresher.refresh_device(): No refresh method found for device, dni = " .. dni .. ", model = " .. device_model) + return + end + + return refresh_method(driver, device, values) +end + +return device_refresher \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/commands.lua b/drivers/ABB/insite-scu200/src/commands.lua new file mode 100644 index 0000000000..453d0f521f --- /dev/null +++ b/drivers/ABB/insite-scu200/src/commands.lua @@ -0,0 +1,111 @@ +local caps = require('st.capabilities') +local log = require('log') +local json = require('dkjson') + +-- Local imports +local utils = require('utils') +local fields = require('fields') +local config = require("config") +local device_manager = require('abb.device_manager') +local connection_monitor = require('connection_monitor') + +-- Commands handler for the bridge and thing devices +local commands = {} + +-- Method for posting the payload to the device +local function post_payload(device, payload) + local dni = utils.get_dni_from_device(device) + local communication_device = device:get_parent_device() or device + local conn_info = communication_device:get_field(fields.CONN_INFO) + + local _, err, status = conn_info:post_device_by_id(dni, payload) + if not err and status == 200 then + log.info("post_payload(): Success, dni = " .. dni) + + return true + else + status = status or "nil" + log.error("post_payload(): Error, err = " .. err .. ", status = " .. status .. ", dni = " .. dni) + + device:offline() + + return false + end +end + +-- Switch on command +function commands.switch_on(driver, device, cmd) + local dni, _ = utils.get_dni_from_device(device) + log.info("commands.switch_on(): Switching on capablity within dni = " .. dni) + + local device_model = utils.get_device_model(device) + + local payload = nil + local event = nil + if device_model == utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE) then + payload = json.encode({capability = cmd.capability, command = cmd.command}) + event = caps.switch.switch.on() + end + + if payload ~= nil and event ~= nil then + local bridge = device:get_parent_device() + + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + local success = post_payload(device, payload) + if success then + device:emit_event(event) + end + end) + end +end + +-- Switch off commands +function commands.switch_off(driver, device, cmd) + local dni, _ = utils.get_dni_from_device(device) + log.info("commands.switch_off(): Switching off capablity within dni = " .. dni) + + local device_model = utils.get_device_model(device) + + local payload = nil + local event = nil + if device_model == utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE) then + payload = json.encode({capability = cmd.capability, command = cmd.command}) + event = caps.switch.switch.off() + end + + if payload ~= nil and event ~= nil then + local bridge = device:get_parent_device() + + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + local success = post_payload(device, payload) + if success then + device:emit_event(event) + end + end) + end +end + +-- Refresh command +function commands.refresh(driver, device, cmd) + local dni, device_type = utils.get_dni_from_device(device) + log.info("commands.refresh(): Refresh capability within dni = " .. dni) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + connection_monitor.check_and_update_connection(driver, device) + local child_devices = device:get_child_list() + + for _, thing_device in ipairs(child_devices) do + device_manager.refresh(driver, thing_device) + end + elseif device_type == fields.DEVICE_TYPE_THING then + local bridge = device:get_parent_device() + + if bridge.thread ~= nil then + bridge.thread:call_with_delay(0, function() -- Run within bridge thread to use the same connection + device_manager.refresh(driver, device) + end) + end + end +end + +return commands \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/config.lua b/drivers/ABB/insite-scu200/src/config.lua new file mode 100644 index 0000000000..8470708d9e --- /dev/null +++ b/drivers/ABB/insite-scu200/src/config.lua @@ -0,0 +1,72 @@ +local config = {} + +-- Device Config +config.DEVICE_TYPE = "LAN" + +config.MANUFACTURER = "ABB" + +config.BRIDGE_PROFILE = "abb.scu200.bridge.v1" +config.BRIDGE_TYPE = "SCU200" +config.BRIDGE_VERSION = "1" + +config.BRIDGE_URN = "urn:" .. config.MANUFACTURER .. ":device:" .. config.BRIDGE_TYPE .. ":" .. config.BRIDGE_VERSION + +config.BRIDGE_CONN_MONITOR_INTERVAL = 300 -- 5 minutes + +-- Edge Child Config +config.EDGE_CHILD_TYPE = "EDGE_CHILD" + +config.EDGE_CHILD_CURRENT_SENSOR_TYPE = "CurrentSensor" +config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE = "EnergyMeterModule" +config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE = "AuxiliaryContact" +config.EDGE_CHILD_OUTPUT_MODULE_TYPE = "OutputModule" +config.EDGE_CHILD_ENERGY_METER_TYPE = "EnergyMeter" +config.EDGE_CHILD_WATER_METER_TYPE = "WaterMeter" +config.EDGE_CHILD_GAS_METER_TYPE = "GasMeter" +config.EDGE_CHILD_USB_ENERGY_METER_TYPE = "USBEnergyMeter" + +config.EDGE_CHILD_CURRENT_SENSOR_VERSION = 1 +config.EDGE_CHILD_ENERGY_METER_MODULE_VERSION = 1 +config.EDGE_CHILD_AUXILIARY_CONTACT_VERSION = 1 +config.EDGE_CHILD_OUTPUT_MODULE_VERSION = 1 +config.EDGE_CHILD_ENERGY_METER_VERSION = 1 +config.EDGE_CHILD_WATER_METER_VERSION = 1 +config.EDGE_CHILD_GAS_METER_VERSION = 1 +config.EDGE_CHILD_USB_ENERGY_METER_VERSION = 1 + +config.EDGE_CHILD_CURRENT_SENSOR_CONSUMPTION_PROFILE = "abb.scu200.current-sensor-consumption.v1" +config.EDGE_CHILD_CURRENT_SENSOR_PRODUCTION_PROFILE = "abb.scu200.current-sensor-production.v1" +config.EDGE_CHILD_AUXILIARY_CONTACT_PROFILE = "abb.scu200.auxiliary-contact.v1" +config.EDGE_CHILD_OUTPUT_MODULE_PROFILE = "abb.scu200.output-module.v1" +config.EDGE_CHILD_ENERGY_METER_PROFILE = "abb.scu200.energy-meter.v1" +config.EDGE_CHILD_WATER_METER_PROFILE = "abb.scu200.water-meter.v1" +config.EDGE_CHILD_GAS_METER_PROFILE = "abb.scu200.gas-meter.v1" +config.EDGE_CHILD_USB_ENERGY_METER_PROFILE = "abb.scu200.usb-energy-meter.v1" + +config.EDGE_CHILD_CURRENT_SENSOR_REFRESH_PERIOD = 30 +config.EDGE_CHILD_ENERGY_METER_MODULE_REFRESH_PERIOD = 30 +config.EDGE_CHILD_AUXILIARY_CONTACT_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_OUTPUT_MODULE_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_ENERGY_METER_REFRESH_PERIOD = 30 +config.EDGE_CHILD_WATER_METER_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_GAS_METER_REFRESH_PERIOD = 300 -- 5 minutes +config.EDGE_CHILD_USB_ENERGY_METER_REFRESH_PERIOD = 30 + +config.EDGE_CHILD_ENERGY_REPORT_INTERVAL = 900 -- 15 minutes + +-- REST API Config +config.REST_API_PORT = 1025 + +-- SSDP Config +config.MC_ADDRESS = "239.255.255.250" +config.MC_PORT = 1900 +config.MC_TIMEOUT = 5 +config.MSEARCH = table.concat({ + "M-SEARCH * HTTP/1.1", + "HOST: 239.255.255.250:1900", + "MAN: \"ssdp:discover\"", + "MX: 5", + "ST: " .. config.BRIDGE_URN +}, "\r\n") + +return config diff --git a/drivers/ABB/insite-scu200/src/connection_monitor.lua b/drivers/ABB/insite-scu200/src/connection_monitor.lua new file mode 100644 index 0000000000..bdeb2b9621 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/connection_monitor.lua @@ -0,0 +1,71 @@ +local log = require("log") + +-- Local imports +local fields = require("fields") +local utils = require("utils") +local discovery = require("discovery") +local eventsource_handler = require("eventsource_handler") +local device_manager = require("abb.device_manager") + +-- Connection monitor for the SCU200 Bridge +local connection_monitor = {} + +function connection_monitor.update_connection(driver, device, bridge_ip) + local bridge_dni = utils.get_dni_from_device(device) + log.info("connection_monitor.update_connection(): Update connection for bridge device: " .. bridge_dni) + + local conn_info = device_manager.get_bridge_connection_info(driver, bridge_dni, bridge_ip) + + if device_manager.is_valid_connection(driver, device, conn_info) then + device:set_field(fields.CONN_INFO, conn_info) + eventsource_handler.create_sse(driver, device) + end +end + +local function find_new_connection(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("find_new_connection(): Find new connection for dni = " .. dni) + + local found_devices = discovery.find_devices() + + if found_devices ~= nil then + local found_device = found_devices[dni] + + if found_device then + log.info("find_new_connection(): Found new connection for dni = " .. dni) + + local ip = found_device.ip + + device:set_field(fields.BRIDGE_IPV4, ip, {persist = true}) + connection_monitor.update_connection(driver, device, ip) + end + end +end + +function connection_monitor.check_and_update_connection(driver, device) + local dni = utils.get_dni_from_device(device) + local conn_info = device:get_field(fields.CONN_INFO) + + if not device_manager.is_valid_connection(driver, device, conn_info) then + log.error("connection_monitor.check_and_update_connection(): Disconnected from device. Try to find new connection for dni = " .. dni) + + find_new_connection(driver, device) + end +end + +-- Method for monitoring the connection of the bridge devices +function connection_monitor.monitor_connections(driver) + local device_list = driver:get_devices() + + for _, device in ipairs(device_list) do + if device:get_field(fields.DEVICE_TYPE) == fields.DEVICE_TYPE_BRIDGE then + local dni = utils.get_dni_from_device(device) + log.info("connection_monitor.monitor_connections(): Monitoring connection for bridge device: " .. dni) + + connection_monitor.check_and_update_connection(driver, device) + device_manager.bridge_monitor(driver, device) + end + end +end + +return connection_monitor \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/discovery.lua b/drivers/ABB/insite-scu200/src/discovery.lua new file mode 100644 index 0000000000..3958c48026 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/discovery.lua @@ -0,0 +1,315 @@ +local log = require "log" +local socket = require('socket') +local cosock = require "cosock" + +-- Local imports +local api = require("abb.api") +local config = require("config") +local utils = require("utils") +local fields = require("fields") + +-- Discovery service run within SmartThings app +local discovery = {} + +local joined_bridge = {} +local joined_thing = {} + +-- Method for setting the device fields +function discovery.set_device_fields(driver, device) + local dni = utils.get_dni_from_device(device) + + if joined_bridge[dni] ~= nil then + log.info("discovery.set_device_field(): Setting device field for bridge: " .. dni) + local bridge_cache_value = driver.datastore.bridge_discovery_cache[dni] + + device:set_field(fields.BRIDGE_IPV4, bridge_cache_value.ip, {persist = true}) + device:set_field(fields.DEVICE_TYPE, fields.DEVICE_TYPE_BRIDGE, {persist = true}) + elseif joined_thing[dni] ~= nil then + log.info("discovery.set_device_field(): Setting device field for thing: " .. dni) + local thing_cache_value = driver.datastore.thing_discovery_cache[dni] + + device:set_field(fields.PARENT_BRIDGE_DNI, thing_cache_value.parent_bridge_dni, {persist = true}) + device:set_field(fields.THING_INFO, thing_cache_value.thing_info, {persist = true}) + device:set_field(fields.DEVICE_TYPE, fields.DEVICE_TYPE_THING, {persist = true}) + else + log.warn("discovery.set_device_field(): Could not set device field for unknown device: " .. dni) + end +end + +-- Method for updating the bridge discovery cache +local function update_bridge_discovery_cache(driver, dni, device) + log.info("update_bridge_discovery_cache(): Updating bridge discovery cache: " .. dni) + + driver.datastore.bridge_discovery_cache[dni] = { + ip = device["ip"] + } +end + +-- Method for updating the thing discovery cache +local function update_thing_discovery_cache(driver, thing_dni, parent_bridge_dni, thing_info) + log.info("update_thing_discovery_cache(): Updating thing discovery cache: " .. thing_dni) + + driver.datastore.thing_discovery_cache[thing_dni] = { + parent_bridge_dni = parent_bridge_dni, + thing_info = thing_info, + } +end + +-- Method for trying to add a new bridge +local function try_add_bridge(driver, dni, device) + log.info("try_add_bridge(): Trying to add bridge: " .. dni) + + local bridge_info = api.get_bridge_info(device["ip"], dni) + if bridge_info == nil then + log.error("try_add_bridge(): Failed to get bridge info for bridge: " .. dni) + return false + end + + update_bridge_discovery_cache(driver, dni, device) + + local metadata = { + type = config.DEVICE_TYPE, + device_network_id = dni, + label = bridge_info.name, + profile = config.BRIDGE_PROFILE, + manufacturer = config.MANUFACTURER, + model = config.BRIDGE_TYPE, + vendor_provided_label = config.BRIDGE_TYPE + } + + local success, err = driver:try_create_device(metadata) + + if success then + log.debug("try_add_bridge(): Bridge created: " .. dni) + + return true + else + log.error("try_add_bridge(): Failed to create bridge: " .. dni) + log.debug("try_add_bridge(): Error: " .. err) + + return false + end +end + +-- Method for trying to add a new thing +local function try_add_thing(driver, parent_device, thing_dni, thing_info) + local parent_device_dni = utils.get_dni_from_device(parent_device) + log.info("try_add_thing(): Trying to add thing: " .. thing_dni .. " of type: " .. thing_info.type .. " on bridge: " .. parent_device_dni) + + update_thing_discovery_cache(driver, thing_dni, parent_device_dni, thing_info) + + if thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE) or thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE) then + log.warn("try_add_thing(): Not supported thing type: " .. thing_info.type) + return false + elseif thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE) and thing_info.properties.isExport then + log.warn("try_add_thing(): Current sensor with production data is not supported") + return false + end + + local profile_ref = utils.get_thing_profile_ref(thing_info) + if profile_ref == nil then + log.error("try_add_thing(): Failed to get profile reference for thing: " .. thing_dni) + return false + end + + local parent_device_id = utils.get_device_id_from_device(parent_device) + + local metadata = { + type = config.EDGE_CHILD_TYPE, + label = thing_info.name, + vendor_provided_label = thing_info.name, + profile = profile_ref, + manufacturer = config.MANUFACTURER, + model = thing_info.type, + parent_device_id = parent_device_id, + parent_assigned_child_key = thing_info.uuid, + } + + local success, err = driver:try_create_device(metadata) + + if success then + log.debug("try_add_thing(): Thing created: " .. thing_dni) + + return true + else + log.error("try_add_thing(): Failed to create thing: " .. thing_dni) + log.debug("try_add_thing(): Error: " .. err) + + return false + end +end + +-- SSDP Response parser +local function parse_ssdp(data) + local res = {} + + res.status = data:sub(0, data:find('\r\n')) + + for line in data:gmatch("[^\r\n]+") do + local _, _, header, value = string.find(line, "([%w-]+):%s*([%a+-:_ /=?]*)") + + if header ~= nil and value ~= nil then + res[header:lower()] = value + end + end + + return res +end + +-- Method for finding devices +function discovery.find_devices() + log.info("discovery.find_devices(): Finding devices") + + -- Initialize UDP socket + local upnp = cosock.socket.udp() + + upnp:setsockname('*', 0) + upnp:setoption("broadcast", true) + upnp:settimeout(config.MC_TIMEOUT) + + -- Broadcast M-SEARCH request + log.info("discovery.find_devices(): Scanning network...") + + upnp:sendto(config.MSEARCH, config.MC_ADDRESS, config.MC_PORT) + + -- Listen for responses + local devices = {} + local start_time = socket.gettime() + + while (socket.gettime() - start_time) < config.MC_TIMEOUT do + local res = upnp:receivefrom() + + if res ~= nil then + local device = parse_ssdp(res) + local dni = string.match(device["usn"], "^uuid:([a-zA-Z0-9-]+)::" .. config.BRIDGE_URN .. "$") + + if dni ~= nil then + local _, _, device_ip = string.find(device["location"], "https?://(%d+%.%d+%.%d+%.%d+):?%d*/?.*") + device["ip"] = device_ip + + devices[dni] = device + end + end + end + + -- Print found devices + if next(devices) then + for dni, device in pairs(devices) do + log.debug("discovery.find_devices(): Device found: " .. utils.dump(device)) + end + else + log.debug("discovery.find_devices(): No devices found") + end + + -- Close the UDP socket + upnp:close() + + log.debug("discovery.find_devices(): Stop scanning network") + + if devices ~= nil then + return devices + end + + return nil +end + +-- Start the discovery of bridges +local function discover_bridges(driver) + log.info("discover_bridges(): Discovering bridges") + + -- Get the known devices + local known_devices = {} + + for _, device in pairs(driver:get_devices()) do + local dni, device_type = utils.get_dni_from_device(device) + known_devices[dni] = device + + log.debug("discover_bridges(): Known devices: " .. dni .. " with type: " .. device_type) + end + + -- Find new devices + local found_devices = discovery.find_devices() + + if found_devices ~= nil then + for dni, device in pairs(found_devices) do + if not known_devices or not known_devices[dni] then + log.info("discover_bridges(): Found new bridge: " .. dni) + + if not joined_bridge[dni] then + if try_add_bridge(driver, dni, device) then + joined_bridge[dni] = true + end + else + log.debug("discover_bridges(): Bridge already joined: " .. dni) + end + else + log.debug("discover_bridges(): Bridge already added: " .. dni) + end + end + end +end + +-- Start the discovery of things +local function discover_things(driver) + log.info("discover_things(): Discovering things") + + -- Get the known devices + local known_devices = {} + + for _, device in pairs(driver:get_devices()) do + local dni, device_type = utils.get_dni_from_device(device) + known_devices[dni] = device + + log.debug("discover_things(): Known devices: " .. dni .. " with type: " .. device_type) + end + + -- Found new devices + for bridge_dni, bridge_cache_value in pairs(driver.datastore.bridge_discovery_cache) do + local bridge_ip = bridge_cache_value.ip + log.info("discover_things(): Fetching things from bridge: " .. bridge_dni .. " at IP: " .. bridge_ip) + + if known_devices[bridge_dni] ~= nil and known_devices[bridge_dni]:get_field(fields.CONN_INFO) ~= nil then + local thing_infos = api.get_thing_infos(bridge_ip, bridge_dni) + + if thing_infos and thing_infos.devices ~= nil then + for _, thing_info in pairs(thing_infos.devices) do + if thing_info ~= nil then + local thing_dni = thing_info.uuid + + log.info("discover_things(): Found thing: " .. thing_dni .. " on bridge: " .. bridge_dni) + + if thing_dni ~= nil then + if not known_devices[thing_dni] then + if try_add_thing(driver, known_devices[bridge_dni], thing_dni, thing_info) then + joined_thing[thing_dni] = true + end + elseif not joined_thing[thing_dni] then + log.debug("discover_things(): Thing already known: " .. thing_dni) + else + log.debug("discover_things(): Thing already joined: " .. thing_dni) + end + end + end + + cosock.socket.sleep(0.2) + end + end + end + end +end + +-- Main function to start the discovery service +function discovery.start(driver, _, should_continue) + log.info("discovery.start(): Starting discovery") + + while should_continue() do + discover_bridges(driver) + discover_things(driver) + + cosock.socket.sleep(0.2) + end + + log.info("discovery.start(): Ending discovery") +end + +return discovery \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/eventsource_handler.lua b/drivers/ABB/insite-scu200/src/eventsource_handler.lua new file mode 100644 index 0000000000..82e7131b87 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/eventsource_handler.lua @@ -0,0 +1,128 @@ +local log = require('log') +local json = require('dkjson') + +-- Local imports +local EventSource = require "lunchbox.sse.eventsource" +local device_manager = require("abb.device_manager") +local device_refresher = require("abb.device_refresher") +local fields = require "fields" +local utils = require "utils" + +local eventsource_handler = {} + +-- Method for handling an incoming SSE event +function eventsource_handler.handle_sse_event(driver, bridge, msg) + log.debug("eventsource_handler.handle_sse_event(): Handling SSE event (TYPE: " .. msg.type .. ", DATA: " .. msg.data .. ")") + + if msg.type == "valueChanged" then + local data = json.decode(msg.data) + + if data ~= nil and next(data) ~= nil then + -- Find the device + local device = nil + + local child_devices = bridge:get_child_list() + for _, thing_device in ipairs(child_devices) do + local dni = utils.get_dni_from_device(thing_device) + + if dni == data.uuid then + device = thing_device + break + end + end + + if device == nil then + log.warn("eventsource_handler.handle_sse_event(): Failed to find the device with dni: " .. data.uuid) + return + end + + -- Prepare the values + local values = {} + + values[data.attribute.name] = data.attribute.value + + -- Refresh the device + if device_refresher.refresh_device(driver, device, values) then + -- Define online status + device:online() + else + log.error("eventsource_handler.handle_sse_event(): Failed to update the device's values") + + -- Set device as offline + device:offline() + end + else + log.error("eventsource_handler.handle_sse_event(): Failed to decode JSON data: " .. msg.data) + end + elseif msg.type == "noDevices" then + log.info("eventsource_handler.handle_sse_event(): No devices to monitor found") + + eventsource_handler.close_sse(driver, bridge) + elseif msg.type == "refreshConnection" then + log.info("eventsource_handler.handle_sse_event(): Refreshing connection") + + eventsource_handler.close_sse(driver, bridge) + eventsource_handler.create_sse(driver, bridge) + else + log.warn("eventsource_handler.handle_sse_event(): Unknown SSE event type: " .. msg.type) + end +end + +-- Method for creating SSE +function eventsource_handler.create_sse(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("eventsource_handler.create_sse(): Creating SSE for dni: " .. dni) + + local conn_info = device:get_field(fields.CONN_INFO) + + if not device_manager.is_valid_connection(driver, device, conn_info) then + log.error("eventsource_handler.create_sse(): Invalid connection for dni: " .. dni) + return + end + + local sse_url = conn_info:get_sse_url() + if not sse_url then + log.error("eventsource_handler.create_sse(): Failed to get sse_url for dni: " .. dni) + return + end + + log.trace("eventsource_handler.create_sse(): Creating SSE EventSource for " .. dni .. " with sse_url: " .. sse_url) + local eventsource = EventSource.new(sse_url, {}, nil, nil) + + eventsource.onmessage = function(msg) + if msg then + eventsource_handler.handle_sse_event(driver, device, msg) + end + end + + eventsource.onerror = function() + log.error("eventsource_handler.create_sse(): Error in the eventsource for dni: " .. dni) + device:offline() + end + + eventsource.onopen = function(msg) + log.info("eventsource_handler.create_sse(): Eventsource has been opened for dni: " .. dni) + device:online() + end + + local old_eventsource = device:get_field(fields.EVENT_SOURCE) + if old_eventsource then + log.info("eventsource_handler.create_sse(): Eventsource has been closed for dni: " .. dni) + old_eventsource:close() + end + device:set_field(fields.EVENT_SOURCE, eventsource) +end + +-- Method for closing SSE +function eventsource_handler.close_sse(driver, device) + local dni = utils.get_dni_from_device(device) + log.info("eventsource_handler.close_sse(): Closing SSE for dni: " .. dni) + + local eventsource = device:get_field(fields.EVENT_SOURCE) + if eventsource then + log.info("eventsource_handler.close_sse(): Closing eventsource for device: " .. dni) + eventsource:close() + end +end + +return eventsource_handler diff --git a/drivers/ABB/insite-scu200/src/fields.lua b/drivers/ABB/insite-scu200/src/fields.lua new file mode 100644 index 0000000000..7c56019cc1 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/fields.lua @@ -0,0 +1,16 @@ +-- Table of constants used to index in to device store fields +local fields = { + BRIDGE_IPV4 = "bridge_ipv4", + THING_INFO = "thing_info", + CONN_INFO = "conn_info", + PARENT_BRIDGE_DNI = "parent_bridge_dni", + EVENT_SOURCE = "eventsource", + DEVICE_TYPE = "devcie_type", + LAST_ENERGY_REPORT = "last_energy_report", + _INIT = "init", + + DEVICE_TYPE_BRIDGE = "bridge", + DEVICE_TYPE_THING = "thing" +} + +return fields \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/init.lua b/drivers/ABB/insite-scu200/src/init.lua new file mode 100644 index 0000000000..df33d18b1c --- /dev/null +++ b/drivers/ABB/insite-scu200/src/init.lua @@ -0,0 +1,45 @@ +local log = require('log') +local Driver = require('st.driver') +local caps = require('st.capabilities') + +-- Local imports +local discovery = require('discovery') +local commands = require('commands') +local config = require('config') +local lifecycles = require('lifecycles') +local connection_monitor = require('connection_monitor') + +-- Driver definition +local driver = Driver("ABB.SCU200", { + discovery = discovery.start, + lifecycle_handlers = lifecycles, + capability_handlers = { + -- Refresh command handler + [caps.refresh.ID] = { + [caps.refresh.commands.refresh.NAME] = commands.refresh + }, + [caps.switch.ID] = { + [caps.switch.commands.on.NAME] = commands.switch_on, + [caps.switch.commands.off.NAME] = commands.switch_off + } + } +}) + +-- Prepare datastores for bridge and thing discovery caches +if driver.datastore.bridge_discovery_cache == nil then + driver.datastore.bridge_discovery_cache = {} +end + +if driver.datastore.thing_discovery_cache == nil then + driver.datastore.thing_discovery_cache = {} +end + +-- Connection monitoring thread +driver:call_on_schedule(config.BRIDGE_CONN_MONITOR_INTERVAL, connection_monitor.monitor_connections, "SCU200 Bridge connection monitoring thread") + +-- Initialize driver +log.info("Starting driver") + +driver:run() + +log.warn("Exiting driver") \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lifecycles.lua b/drivers/ABB/insite-scu200/src/lifecycles.lua new file mode 100644 index 0000000000..83cdfa0eb7 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lifecycles.lua @@ -0,0 +1,87 @@ +local log = require('log') + +-- Local imports +local fields = require('fields') +local utils = require('utils') +local discovery = require('discovery') +local commands = require('commands') +local connection_monitor = require('connection_monitor') +local eventsource_handler = require("eventsource_handler") + +-- Lifecycles handlers for the driver +local lifecycles = {} + +-- Lifecycle handler for a device which has been initialized +function lifecycles.init(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + + -- Verify if the device has already been initialized + if device:get_field(fields._INIT) then + log.info("lifecycles.init(): Device already initialized: " .. dni .. " of type: " .. device_type) + return + end + + log.info("lifecycles.init(): Initializing device: " .. dni .. " of type: " .. device_type) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + if driver.datastore.bridge_discovery_cache[dni] then + log.debug("lifecycles.init(): Setting unsaved bridge fields") + discovery.set_device_fields(driver, device) + end + + local bridge_ip = device:get_field(fields.BRIDGE_IPV4) + + connection_monitor.update_connection(driver, device, bridge_ip) + elseif device_type == fields.DEVICE_TYPE_THING then + if driver.datastore.thing_discovery_cache[dni] then + log.debug("lifecycles.init(): Setting unsaved thing fields") + discovery.set_device_fields(driver, device) + end + + -- Refresh the device manually + commands.refresh(driver, device, nil) + + -- Refresh schedule + local refresh_period = utils.get_thing_refresh_period(device) + + device.thread:call_on_schedule( + refresh_period, + function () + return commands.refresh(driver, device, nil) + end, + "Refresh schedule") + end + + -- Set the device as initialized + device:set_field(fields._INIT, true, {persist = false}) +end + +-- Lifecycle handler for a device which has been added +function lifecycles.added(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + log.info("lifecycles.added(): Adding device: " .. dni .. " of type: " .. device_type) + + -- Force the initialization due to cases where the device is not initialized after being added + lifecycles.init(driver, device) +end + +-- Lifecycle handler for a device which has been removed +function lifecycles.removed(driver, device) + local dni, device_type = utils.get_dni_from_device(device) + log.info("lifecycles.removed(): Removing device: " .. dni .. " of type: " .. device_type) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + log.debug("lifecycles.removed(): Closing SSE for device: " .. dni) + + eventsource_handler.close_sse(driver, device) + elseif device_type == fields.DEVICE_TYPE_THING then + log.debug("lifecycles.removed(): Removing schedules for device: " .. dni) + + -- Remove the schedules to avoid unnecessary CPU processing + for timer in pairs(device.thread.timers) do + device.thread:cancel_timer(timer) + end + end +end + +return lifecycles \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lunchbox/init.lua b/drivers/ABB/insite-scu200/src/lunchbox/init.lua new file mode 100644 index 0000000000..d454f39e68 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lunchbox/init.lua @@ -0,0 +1,4 @@ +local RestClient = require "lunchbox.rest" +local EventSource = require "lunchbox.sse.eventsource" + +return {RestClient = RestClient, EventSource = EventSource} \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lunchbox/rest.lua b/drivers/ABB/insite-scu200/src/lunchbox/rest.lua new file mode 100644 index 0000000000..b7c03525a6 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lunchbox/rest.lua @@ -0,0 +1,427 @@ +---@class ChunkedResponse : Response +---@field package _received_body boolean +---@field package _parsed_headers boolean +---@field public new fun(status_code: number, socket: table?): ChunkedResponse +---@field public fill_body fun(self: ChunkedResponse): string? +---@field public append_body fun(self: ChunkedResponse, next_chunk_body: string): ChunkedResponse + +local socket = require "cosock.socket" +local utils = require "utils" +local lb_utils = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" --[[@as ChunkedResponse]] +local json = require('dkjson') + +local api_version = require("version").api + +local RestCallStates = { + SEND = "Send", + RECEIVE = "Receive", + RETRY = "Retry", + RECONNECT = "Reconnect", + COMPLETE = "Complete", +} + +local function connect(client) + local port = 80 + local use_ssl = false + + if client.base_url.scheme == "https" then + port = 443 + use_ssl = true + end + + if client.base_url.port ~= port then port = client.base_url.port end + local sock, err = client.socket_builder(client.base_url.host, port, use_ssl) + + if sock == nil then + client.socket = nil + return false, err + end + + client.socket = sock + return true +end + +local function reconnect(client) + if client.socket ~= nil then + client.socket:close() + client.socket = nil + end + return connect(client) +end + +---comment +---@param client RestClient +---@param request Request +---@return integer? bytes_sent +---@return string? err_msg +---@return integer idx +local function send_request(client, request) + if client.socket == nil then + return nil, "no socket available", 0 + end + local payload = request:serialize() + + local bytes, err, idx = nil, nil, 0 + + repeat bytes, err, idx = client.socket:send(payload, idx + 1, #payload) until (bytes == #payload) + or (err ~= nil) + + return bytes, err, idx +end + +local function parse_chunked_response(original_response, sock) + local ChunkedTransferStates = { + EXPECTING_CHUNK_LENGTH = "ExpectingChunkLength", + EXPECTING_BODY_CHUNK = "ExpectingBodyChunk", + } + + local full_response = Response.new(original_response.status, nil) --[[@as ChunkedResponse]] + + for header in original_response.headers:iter() do full_response.headers:append_chunk(header) end + + local original_body, err = original_response:get_body() + if type(original_body) ~= "string" or err ~= nil then + return original_body, (err or "unexpected nil in error position") + end + local next_chunk_bytes = tonumber(original_body, 16) + local next_chunk_body = "" + local bytes_read = 0; + + local state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + + repeat + local pat = nil + local next_recv, next_err, partial = nil, nil, nil + + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + pat = next_chunk_bytes + else + pat = "*l" + end + + next_recv, next_err, partial = sock:receive(pat) + + if next_err ~= nil then + if string.lower(next_err) == "closed" then + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + next_chunk_bytes = 0 + else + return nil, next_err + end + else + return nil, ("unexpected error reading chunked transfer: " .. next_err) + end + end + + if next_recv ~= nil and #next_recv >= 1 then + if state == ChunkedTransferStates.EXPECTING_BODY_CHUNK then + bytes_read = bytes_read + #next_recv + next_chunk_body = next_chunk_body .. next_recv + + if bytes_read >= next_chunk_bytes then + full_response = full_response:append_body(next_chunk_body) + next_chunk_body = "" + bytes_read = 0 + + state = ChunkedTransferStates.EXPECTING_CHUNK_LENGTH + end + elseif state == ChunkedTransferStates.EXPECTING_CHUNK_LENGTH then + next_chunk_bytes = tonumber(next_recv, 16) + + state = ChunkedTransferStates.EXPECTING_BODY_CHUNK + end + end + until next_chunk_bytes == 0 + + local _ = sock:receive("*l") -- clear the trailing CRLF + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function recv_additional_response(original_response, sock) + local full_response = Response.new(original_response.status, nil) + local headers = original_response:get_headers() + local content_length_str = headers:get_one("Content-Length") + local content_length = nil + local bytes_read = 0 + if content_length_str then + content_length = math.tointeger(content_length_str) + end + + local next_recv, next_err, partial + + repeat + next_recv, next_err, partial = sock:receive(content_length - bytes_read) + + if next_recv ~= nil and #next_recv >= 1 then + full_response:append_body(next_recv) + bytes_read = bytes_read + #next_recv + end + + if partial ~= nil and #partial >= 1 then + full_response:append_body(partial) + bytes_read = bytes_read + #partial + end + until next_err == "closed" or bytes_read >= content_length + + full_response._received_body = true + full_response._parsed_headers = true + + return full_response +end + +local function handle_response(sock) + if api_version >= 9 then + local response, err = Response.tcp_source(sock) + if err or (not response) then return response, (err or "unknown error") end + return response, response:fill_body() + end + -- called select right before passing in so we receive immediately + local initial_recv, initial_err, partial = Response.source(function() return sock:receive('*l') end) + + local full_response = nil + + if initial_recv ~= nil then + local headers = initial_recv:get_headers() + + if headers:get_one("Content-Length") then + full_response = recv_additional_response(initial_recv, sock) + elseif headers and headers:get_one("Transfer-Encoding") == "chunked" then + local response, err = parse_chunked_response(initial_recv, sock) + if err ~= nil then + return nil, err + end + full_response = response + else + full_response = initial_recv + end + + return full_response + else + return nil, initial_err, partial + end +end + +local function execute_request(client, request, retry_fn) + if not client._active then + return nil, "Called `execute request` on a terminated REST Client", nil + end + + if client.socket == nil then + local success, err = connect(client) + if not success then return nil, err, nil end + end + + local should_retry = retry_fn + + if type(should_retry) ~= "function" then + should_retry = function() return false end + end + + -- send output + local _bytes_sent, send_err, _idx = nil, nil, 0 + -- recv output + local response, recv_err, partial = nil, nil, nil + -- return values + local ret, err = nil, nil + + local backoff = utils.backoff_builder(60, 1, 0.1) + local current_state = RestCallStates.SEND + + repeat + local retry = should_retry() + if current_state == RestCallStates.SEND then + backoff = utils.backoff_builder(60, 1, 0.1) + _bytes_sent, send_err, _idx = send_request(client, request) + + if not send_err then + current_state = RestCallStates.RECEIVE + elseif retry then + if string.lower(send_err) == "closed" or string.lower(send_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = send_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECEIVE then + response, recv_err, partial = handle_response(client.socket) + + if not recv_err then + ret = response + err = nil + current_state = RestCallStates.COMPLETE + elseif retry then + if string.lower(recv_err) == "closed" or string.lower(recv_err):match("broken pipe") then + current_state = RestCallStates.RECONNECT + else + current_state = RestCallStates.RETRY + end + else + ret = nil + err = recv_err + current_state = RestCallStates.COMPLETE + end + elseif current_state == RestCallStates.RECONNECT then + local success, reconn_err = reconnect(client) + if success then + current_state = RestCallStates.RETRY + elseif not retry then + ret = nil + err = reconn_err + current_state = RestCallStates.COMPLETE + else + socket.sleep(backoff()) + end + elseif current_state == RestCallStates.RETRY then + bytes_sent, send_err, _idx = nil, nil, 0 + response, recv_err, partial = nil, nil, nil + current_state = RestCallStates.SEND + socket.sleep(backoff()) + end + until current_state == RestCallStates.COMPLETE + + return ret, err, partial +end + +---@class RestClient +--- +---@field base_url table `net.url` URL table +---@field socket table `cosock` TCP socket +local RestClient = {} +RestClient.__index = RestClient + +function RestClient.one_shot_get(full_url, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + + local query_params = "" + if url_table.query ~= nil then + query_params = "?" + + for param, value in pairs(url_table.query) do + query_params = query_params .. param .. "=" .. value .. "&" + end + + query_params = query_params:sub(1, -2) + end + + local client = RestClient.new(url_table.scheme .. "://" .. url_table.authority, socket_builder) + local ret, err = client:get(url_table.path .. query_params, additional_headers) + + client:shutdown() + + return ret, err +end + +function RestClient.one_shot_post(full_url, body, additional_headers, socket_builder) + local url_table = lb_utils.force_url_table(full_url) + + local query_params = "" + if url_table.query ~= nil then + query_params = "?" + + for param, value in pairs(url_table.query) do + query_params = query_params .. param .. "=" .. value .. "&" + end + + query_params = query_params:sub(1, -2) + end + + if type(body) == "table" then + body = json.encode(body) + end + + local client = RestClient.new(url_table.scheme .. "://" .. url_table.authority, socket_builder) + local ret, err = client:post(url_table.path .. query_params, body, additional_headers) + + client:shutdown() + + return ret, err +end + +function RestClient:close_socket() + if self.socket ~= nil and self._active then + self.socket:close() + self.socket = nil + end +end + +function RestClient:shutdown() + self:close_socket() + self._active = false +end + +function RestClient:update_base_url(new_url) + if self.socket ~= nil then + self.socket:close() + self.socket = nil + end + + self.base_url = lb_utils.force_url_table(new_url) +end + +function RestClient:get(path, additional_headers, retry_fn) + local request = Request.new("GET", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + return execute_request(self, request, retry_fn) +end + +function RestClient:post(path, body_string, additional_headers, retry_fn) + local request = Request.new("POST", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient:put(path, body_string, additional_headers, retry_fn) + local request = Request.new("PUT", path, nil):add_header( + "user-agent", "smartthings-lua-edge-driver" + ):add_header("host", string.format("%s", self.base_url.host)):add_header( + "connection", "keep-alive" + ) + + if additional_headers ~= nil and type(additional_headers) == "table" then + for k, v in pairs(additional_headers) do request = request:add_header(k, v) end + end + + request = request:append_body(body_string) + + return execute_request(self, request, retry_fn) +end + +function RestClient.new(base_url, sock_builder) + base_url = lb_utils.force_url_table(base_url) + + if type(sock_builder) ~= "function" then sock_builder = utils.labeled_socket_builder() end + + return + setmetatable({base_url = base_url, socket_builder = sock_builder, socket = nil, _active = true}, RestClient) +end + +return RestClient \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lunchbox/sse/eventsource.lua b/drivers/ABB/insite-scu200/src/lunchbox/sse/eventsource.lua new file mode 100644 index 0000000000..d9a5930df2 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lunchbox/sse/eventsource.lua @@ -0,0 +1,523 @@ +local cosock = require "cosock" +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" +local json = require "dkjson" + +local log = require "log" +local util = require "lunchbox.util" +local Request = require "luncheon.request" +local Response = require "luncheon.response" + +--- A pure Lua implementation of the EventSource interface. +--- The EventSource interface represents the client end of an HTTP(S) +--- connection that receives an event stream following the Server-Sent events +--- specification. +--- +--- MDN Documentation for EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource +--- HTML Spec: https://html.spec.whatwg.org/multipage/server-sent-events.html +--- +--- @class EventSource +--- @field public url table A `net.url` table representing the URL for the connection +--- @field public ready_state number Enumeration of the ready states outlined in the spec. +--- @field public onopen function in-line callback for on-open events +--- @field public onmessage function in-line callback for on-message events +--- @field public onerror function in-line callback for on-error events; error callbacks will fire +--- @field package _reconnect boolean flag that says whether or not the client should attempt to reconnect on close. +--- @field package _reconnect_time_millis number The amount of time to wait between reconnects, in millis. Can be sent by the server. +--- @field package _sock_builder function|nil optional. If this function exists, it will be called to create a new TCP socket on connection. +--- @field package _sock table? the TCP socket for the connection +--- @field package _needs_more boolean flag to track whether or not we're still expecting mroe on this source before we dispatch +--- @field package _last_field string the last field the parsing path saw, in case it needs to append more to its value +--- @field package _extra_headers table a table of string:string key-value pairs that will be inserted in to the initial requests's headers. +--- @field package _parse_buffers table inner state, keeps track of the various event stream buffers in between dispatches. +--- @field package _listeners table event listeners attached using the add_event_listener API instead of the inline callbacks. +local EventSource = {} +EventSource.__index = EventSource + +--- The Ready States that an EventSource can be in. We use base 0 to match the specification. +EventSource.ReadyStates = util.read_only { + CONNECTING = 0, -- The connection has not yet been established + OPEN = 1, -- The connection is open + CLOSED = 2 -- The connection has closed +} + +--- The event types supported by this source, patterned after their values in JavaScript. +EventSource.EventTypes = util.read_only { + ON_OPEN = "open", + ON_MESSAGE = "message", + ON_ERROR = "error", +} + +--- Helper function that creates the initial Request to start the stream. +--- @function create_request +--- @local +--- @param url_table table a net.url table +--- @param extra_headers table a set of key/value pairs (strings) to capture any extra HTTP headers needed +--- @param body table a set of key/value pairs (strings) to be sent as the body of the initial POST request. +local function create_request(url_table, extra_headers, body) + local request = Request.new("POST", url_table.path, nil) + :add_header("user-agent", "smartthings-lua-edge-driver") + :add_header("host", string.format("%s", url_table.host)) + :add_header("connection", "keep-alive") + :add_header("accept", "text/event-stream") + :add_header("content-type", "application/json") + + if type(extra_headers) == "table" then + for k, v in pairs(extra_headers) do + request = request:add_header(k, v) + end + end + + local encoded_body = json.encode(body) + request = request:append_body(encoded_body) + + return request +end + +--- Helper function to send the request and kick off the stream. +--- @function send_stream_start_request +--- @local +--- @param payload string the entire string buffer to send +--- @param sock table the TCP socket to send it over +local function send_stream_start_request(payload, sock) + local bytes, err, idx = nil, nil, 0 + + repeat + bytes, err, idx = sock:send(payload, idx + 1, #payload) + until (bytes == #payload) or (err ~= nil) + + if err then + log.error_with({ hub_logs = true }, "send error: " .. err) + end + + return bytes, err, idx +end + +--- Helper function to create an table representing an event from the source's parse buffers. +--- @function make_event +--- @local +--- @param source EventSource +local function make_event(source) + local event_type = nil + + if #source._parse_buffers["event"] > 0 then + event_type = source._parse_buffers["event"] + end + + return { + type = event_type or "message", + data = source._parse_buffers["data"], + origin = source.url.scheme .. "://" .. source.url.host, + lastEventId = source._parse_buffers["id"] + } +end + +--- SSE spec for dispatching an event: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage +--- @function dispatch_event +--- @local +--- @param source EventSource +local function dispatch_event(source) + local data_buffer = source._parse_buffers["data"] + local is_blank_line = data_buffer ~= nil and + (#data_buffer == 0) or + data_buffer == "\n" or + data_buffer == "\r" or + data_buffer == "\r\n" + if data_buffer ~= nil and not is_blank_line then + local event = util.read_only(make_event(source)) + + if type(source.onmessage) == "function" then + source.onmessage(event) + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_MESSAGE]) do + if type(listener) == "function" then + listener(event) + end + end + end + + source._parse_buffers["event"] = "" + source._parse_buffers["data"] = "" +end + +local valid_fields = util.read_only { + ["event"] = true, + ["data"] = true, + ["id"] = true, + ["retry"] = true +} + +-- An event stream "line" can end in more than one way; from the spec: +-- Lines must be separated by either +-- a U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, +-- a single U+000A LINE FEED (LF) character, +-- or a single U+000D CARRIAGE RETURN (CR) character. +-- +-- util.iter_string_lines won't suffice here because: +-- a.) it assumes \n, and +-- b.) it doesn't differentiate between a "line" that ends without a newline and one that does. +-- +-- h/t to github.com/FreeMasen for the suggestions on the efficient implementation of this +local function find_line_endings(chunk) + local r_idx, n_idx = string.find(chunk, "[\r\n]+") + if r_idx == nil or r_idx == n_idx then + -- 1 character or no match + return r_idx, n_idx + end + local slice = string.sub(chunk, r_idx, n_idx) + if slice == "\r\n" then + return r_idx, n_idx + end + -- invalid multi character match, return first character only + return r_idx, r_idx +end + +local function event_lines(chunk) + local remaining = chunk + local line_end, rn_end + local remainder_sent = false + return function() + line_end, rn_end = find_line_endings(remaining) + if not line_end then + if remainder_sent or (not remaining) or #remaining == 0 then + return nil + else + remainder_sent = true + return remaining, false + end + end + local next_line = string.sub(remaining, 1, line_end - 1) + remaining = string.sub(remaining, rn_end + 1) + return next_line, true + end +end +--- SSE spec for interpreting an event stream: +--- https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface +--- @function parse +--- @local +--- @param source EventSource +--- @param recv string the received payload from the last socket receive +local function sse_parse_chunk(source, recv) + for line, complete in event_lines(recv) do + if not source._needs_more and (#line == 0 or (not line:match("([%w%p]+)"))) then -- empty/blank lines indicate dispatch + dispatch_event(source) + elseif source._needs_more then + local append = line + if source._last_field == "data" and complete then append = append .. "\n" end + if complete then source._needs_more = false end + source._parse_buffers[source._last_field] = source._parse_buffers[source._last_field] .. append + else + if line:sub(1, 1) ~= ":" then -- ignore any complete lines that start w/ a colon + local matches = line:gmatch("(%w*)(:*)(.*)") -- colon after field is optional, in that case it's a field w/ no value + + for field, _colon, value in matches do + value = value:gsub("^[^%g]", "", 1) -- trim a single leading space character + + if valid_fields[field] then + source._last_field = field + if field == "retry" then + local new_time = tonumber(value, 10) + if type(new_time) == "number" then + source._reconnect_time_millis = new_time + end + elseif field == "data" then + local append = (value or "") + -- if complete then append = append .. "\n" end + source._parse_buffers[field] = source._parse_buffers[field] .. append + elseif field == "id" then + -- skip ID's if they contain the NULL character + if not string.find(value, '\0') then + source._parse_buffers[field] = value + end + else + source._parse_buffers[field] = value + end + end + source._needs_more = source._needs_more or (not complete) + end + end + end + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CONNECTING state. +--- @function connecting_action +--- @local +--- @param source EventSource +local function connecting_action(source) + if not source._sock then + if type(source._sock_builder) == "function" then + source._sock = source._sock_builder() + else + source._sock, err = socket.tcp() + if err ~= nil then return nil, err end + + _, err = source._sock:settimeout(60) + if err ~= nil then return nil, err end + + _, err = source._sock:connect(source.url.host, source.url.port) + if err ~= nil then return nil, err end + + _, err = source._sock:setoption("keepalive", true) + if err ~= nil then return nil, err end + + if source.url.scheme == "https" then + source._sock, err = ssl.wrap(source._sock, { + mode = "client", + protocol = "any", + verify = "none", + options = "all" + }) + if err ~= nil then return nil, err end + + _, err = source._sock:dohandshake() + if err ~= nil then return nil, err end + end + end + end + + local request = create_request(source.url, source._extra_headers, source._body) + + local last_event_id = source._parse_buffers["id"] + + if last_event_id ~= nil and #last_event_id > 0 then + request = request:add_header("Last-Event-ID", last_event_id) + end + + local _, err, _ = send_stream_start_request(request:serialize(), source._sock) + + if err ~= nil then + return nil, err + end + + local response + response, err = Response.tcp_source(source._sock) + + if not response or err ~= nil then + return nil, err or "nil response from Response.tcp_source" + end + + if response.status ~= 200 then + return nil, "Server responded with status other than 200 OK", { response.status, response.status_msg } + end + + local headers, err = response:get_headers() + if err ~= nil then + return nil, err + end + local content_type = string.lower((headers and headers:get_one('content-type') or "none")) + if not content_type:find("text/event-stream", 1, true) then + local err_msg = "Expected content type of text/event-stream in response headers, received: " .. content_type + return nil, err_msg + end + + source.ready_state = EventSource.ReadyStates.OPEN + + if type(source.onopen) == "function" then + source.onopen() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_OPEN]) do + if type(listener) == "function" then + listener() + end + end +end +--- Helper function that captures the cyclic logic of the EventSource while in the OPEN state. +--- @function open_action +--- @local +--- @param source EventSource +local function open_action(source) + local recv, err, partial = source._sock:receive('*l') + + if err then + --- connection is fine but there was nothing + --- to be read from the other end so we just + --- early return. + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + + -- the number of bytes to read per the chunked encoding spec + local recv_as_num = tonumber(recv, 16) + + if recv_as_num ~= nil and recv_as_num == 0 then + return -- the stream has ended + end + + if recv_as_num ~= nil then + recv, err, partial = source._sock:receive(recv_as_num) + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + + local _, err, partial = source._sock:receive('*l') -- clear the final line + + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + source._sock:close() + source._sock = nil + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial + end + end + sse_parse_chunk(source, recv) + else + local recv_dbg = recv or "" + if #recv_dbg == 0 then + recv_dbg = "" + end + recv_dbg = recv_dbg:gsub("\r\n", ""):gsub("\n", ""):gsub("\r", "") + log.error_with({ hub_logs = true }, string.format("Received %s while expecting a chunked encoding payload length (hex number)\n", recv_dbg)) + end +end + +--- Helper function that captures the cyclic logic of the EventSource while in the CLOSED state. +--- @function closed_action +--- @local +--- @param source EventSource +local function closed_action(source) + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + + if source._reconnect then + if type(source.onerror) == "function" then + source.onerror() + end + + for _, listener in ipairs(source._listeners[EventSource.EventTypes.ON_ERROR]) do + if type(listener) == "function" then + listener() + end + end + + local sleep_time_secs = source._reconnect_time_millis / 1000.0 + socket.sleep(sleep_time_secs) + + source.ready_state = EventSource.ReadyStates.CONNECTING + end +end + +local state_actions = { + [EventSource.ReadyStates.CONNECTING] = connecting_action, + [EventSource.ReadyStates.OPEN] = open_action, + [EventSource.ReadyStates.CLOSED] = closed_action +} + +--- Create a new EventSource. The only required parameter is the URL, which can +--- be a string or a net.url table. The string form will be converted to a net.url table. +--- +--- @param url string|table a string or a net.url table representing the complete URL (minimally a scheme/host/path, port optional) for the event stream. +--- @param extra_headers table|nil an optional table of key-value pairs (strings) to be added to the initial POST request +--- @param body table|nil an optional table of key-value pairs (strings) to be sent as the body of the initial POST request +--- @param sock_builder function|nil an optional function to be used to create the TCP socket for the stream. If nil, a set of defaults will be used to create a new TCP socket. +--- @return EventSource a new EventSource +function EventSource.new(url, extra_headers, body, sock_builder) + local url_table = util.force_url_table(url) + + if not url_table.port then + if url_table.scheme == "http" then + url_table.port = 80 + elseif url_table.scheme == "https" then + url_table.port = 443 + end + end + + local sock = nil + + if type(sock_builder) == "function" then + sock = sock_builder() + end + + local source = setmetatable({ + url = url_table, + ready_state = EventSource.ReadyStates.CONNECTING, + onopen = nil, + onmessage = nil, + onerror = nil, + _needs_more = false, + _last_field = nil, + _reconnect = true, + _reconnect_time_millis = 15 * 1000, + _sock_builder = sock_builder, + _sock = sock, + _extra_headers = extra_headers, + _body = body, + _parse_buffers = { + ["data"] = "", + ["id"] = "", + ["event"] = "", + }, + _listeners = { + [EventSource.EventTypes.ON_OPEN] = {}, + [EventSource.EventTypes.ON_MESSAGE] = {}, + [EventSource.EventTypes.ON_ERROR] = {} + }, + }, EventSource) + + cosock.spawn(function() + local st_utils = require "st.utils" + while true do + if source.ready_state == EventSource.ReadyStates.CLOSED and not source._reconnect then + return + end + local _, action_err, partial = state_actions[source.ready_state](source) + if action_err ~= nil then + if action_err ~= "timeout" or action_err ~= "wantread" then + log.error_with({ hub_logs = true }, "Event Source Coroutine State Machine error: " .. action_err) + if partial ~= nil and #partial > 0 then + log.error_with({ hub_logs = true }, st_utils.stringify_table(partial, "\tReceived Partial", true)) + end + source.ready_state = EventSource.ReadyStates.CLOSED + end + end + end + end) + + return source +end + +--- Close the event source, signalling that a reconnect is not desired +function EventSource:close() + self._reconnect = false + if self._sock ~= nil then + self._sock:close() + end + self._sock = nil + self.ready_state = EventSource.ReadyStates.CLOSED +end + +--- Add a callback to the event source +---@param listener_type string One of "message", "open", or "error" +---@param listener function the callback to be called in case of an event. Open and Error events have no payload. The message event will have a single argument, a table. +function EventSource:add_event_listener(listener_type, listener) + local list = self._listeners[listener_type] + + if list then + table.insert(list, listener) + end +end + +return EventSource \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/lunchbox/util.lua b/drivers/ABB/insite-scu200/src/lunchbox/util.lua new file mode 100644 index 0000000000..6eb94407eb --- /dev/null +++ b/drivers/ABB/insite-scu200/src/lunchbox/util.lua @@ -0,0 +1,46 @@ +local net_url = require "net.url" + +local util = {} + +util.force_url_table = function(url) + if type(url) ~= "table" then url = net_url.parse(url) end + + if not url.port then + if url.scheme == "http" then + url.port = 80 + elseif url.scheme == "https" then + url.port = 443 + end + end + + return url +end + +util.read_only = function(tbl) + if type(tbl) == "table" then + local proxy = {} + local mt = { -- create metatable + __index = tbl, + __newindex = function(t, k, v) error("attempt to update a read-only table", 2) end, + } + setmetatable(proxy, mt) + return proxy + else + return tbl + end +end + +util.iter_string_lines = function(str) + if str:sub(-1) ~= "\n" then str = str .. "\n" end + + return str:gmatch("(.-)\n") +end + +util.copy_data = function(tbl) + local ret = {} + for k, v in pairs(tbl) do ret[k] = v end + + return ret +end + +return util \ No newline at end of file diff --git a/drivers/ABB/insite-scu200/src/utils.lua b/drivers/ABB/insite-scu200/src/utils.lua new file mode 100644 index 0000000000..139b5503c5 --- /dev/null +++ b/drivers/ABB/insite-scu200/src/utils.lua @@ -0,0 +1,219 @@ +local log = require("log") +local socket = require "cosock.socket" +local ssl = require "cosock.ssl" + +-- Local imports +local config = require("config") +local fields = require("fields") + +-- Utility functions for the SmartThings edge driver +local utils = {} + +-- Get the device id from the device +function utils.get_dni_from_device(device) + if device.parent_assigned_child_key then + local thing_dni = device.parent_assigned_child_key + + return thing_dni, fields.DEVICE_TYPE_THING + else + local bridge_dni = device.device_network_id + + return bridge_dni, fields.DEVICE_TYPE_BRIDGE + end +end + +-- Get the device ID +function utils.get_device_id_from_device(device) + return device.st_store.id +end + +-- Get the device model +function utils.get_device_model(device) + local thing_info = device:get_field(fields.THING_INFO) + + if thing_info == nil then + return nil + end + + return thing_info.type +end + +-- Get the device IP address +function utils.get_device_ip_address(device) + local _, device_type = utils.get_dni_from_device(device) + + if device_type == fields.DEVICE_TYPE_BRIDGE then + return device:get_field(fields.BRIDGE_IPV4) + else + local bridge = device:get_parent_device() + + return bridge:get_field(fields.BRIDGE_IPV4) + end +end + +-- Method for getting edge child device version by type +function utils.get_edge_child_device_version(edge_child_device_type) + local edge_child_device_versions = { + [config.EDGE_CHILD_CURRENT_SENSOR_TYPE] = config.EDGE_CHILD_CURRENT_SENSOR_VERSION, + [config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE] = config.EDGE_CHILD_ENERGY_METER_MODULE_VERSION, + [config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE] = config.EDGE_CHILD_AUXILIARY_CONTACT_VERSION, + [config.EDGE_CHILD_OUTPUT_MODULE_TYPE] = config.EDGE_CHILD_OUTPUT_MODULE_VERSION, + [config.EDGE_CHILD_ENERGY_METER_TYPE] = config.EDGE_CHILD_ENERGY_METER_VERSION, + [config.EDGE_CHILD_WATER_METER_TYPE] = config.EDGE_CHILD_WATER_METER_VERSION, + [config.EDGE_CHILD_GAS_METER_TYPE] = config.EDGE_CHILD_GAS_METER_VERSION, + [config.EDGE_CHILD_USB_ENERGY_METER_TYPE] = config.EDGE_CHILD_USB_ENERGY_METER_VERSION + } + + return edge_child_device_versions[edge_child_device_type] +end + +-- Method for getting the thing exact type +function utils.get_thing_exact_type(edge_child_device_type) + local device_version = utils.get_edge_child_device_version(edge_child_device_type) + + if device_version == nil then + return nil + end + + return config.MANUFACTURER .. "_" .. config.BRIDGE_TYPE .. "_" .. edge_child_device_type .. "_" .. device_version +end + +-- Method for getting the thing profile reference +function utils.get_thing_profile_ref(thing_info) + if thing_info.type == utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE) then + if thing_info.properties.isExport then + return config.EDGE_CHILD_CURRENT_SENSOR_PRODUCTION_PROFILE + else + return config.EDGE_CHILD_CURRENT_SENSOR_CONSUMPTION_PROFILE + end + end + + local thing_profiles = { + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = config.EDGE_CHILD_ENERGY_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = config.EDGE_CHILD_AUXILIARY_CONTACT_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = config.EDGE_CHILD_OUTPUT_MODULE_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = config.EDGE_CHILD_ENERGY_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = config.EDGE_CHILD_WATER_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = config.EDGE_CHILD_GAS_METER_PROFILE, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = config.EDGE_CHILD_USB_ENERGY_METER_PROFILE + } + + return thing_profiles[thing_info.type] +end + +-- Method for getting the thing refresh period +function utils.get_thing_refresh_period(device) + local device_model = utils.get_device_model(device) + + local thing_refresh_periods = { + [utils.get_thing_exact_type(config.EDGE_CHILD_CURRENT_SENSOR_TYPE)] = config.EDGE_CHILD_CURRENT_SENSOR_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_MODULE_TYPE)] = config.EDGE_CHILD_ENERGY_METER_MODULE_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_AUXILIARY_CONTACT_TYPE)] = config.EDGE_CHILD_AUXILIARY_CONTACT_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_OUTPUT_MODULE_TYPE)] = config.EDGE_CHILD_OUTPUT_MODULE_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_ENERGY_METER_TYPE)] = config.EDGE_CHILD_ENERGY_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_WATER_METER_TYPE)] = config.EDGE_CHILD_WATER_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_GAS_METER_TYPE)] = config.EDGE_CHILD_GAS_METER_REFRESH_PERIOD, + [utils.get_thing_exact_type(config.EDGE_CHILD_USB_ENERGY_METER_TYPE)] = config.EDGE_CHILD_USB_ENERGY_METER_REFRESH_PERIOD + } + + return thing_refresh_periods[device_model] +end + +-- Method for dumping a table to string +function utils.dump(o) + if type(o) == "table" then + local s = '{' + + for k,v in pairs(o) do + if type(k) ~= "number" then k = '"'..k..'"' end + s = s .. ' ['..k..'] = ' .. utils.dump(v) .. ',' + end + + return s .. '} ' + else + return tostring(o) + end +end + +-- Method for building a exponential backoff time value generator +function utils.backoff_builder(max, inc, rand) + local count = 0 + inc = inc or 1 + + return function() + local randval = 0 + if rand then + randval = math.random() * rand * 2 - rand + end + + local base = inc * (2 ^ count - 1) + count = count + 1 + + -- ensure base backoff (not including random factor) is less than max + if max then base = math.min(base, max) end + + -- ensure total backoff is >= 0 + return math.max(base + randval, 0) + end +end + +-- Method for creating a labeled socket +function utils.labeled_socket_builder(label, ssl_config) + label = (label or "") + if #label > 0 then + label = label .. " " + end + + if not ssl_config then + ssl_config = { mode = "client", protocol = "any", verify = "none", options = "all" } + end + + local function make_socket(host, port, wrap_ssl) + log.info("utils.labeled_socket_builder(): Creating TCP socket for REST Connection: " .. label) + local _ = nil + local sock, err = socket.tcp() + + if err ~= nil or (not sock) then + return nil, (err or "unknown error creating TCP socket") + end + + log.debug("utils.labeled_socket_builder(): Setting TCP socket timeout for REST Connection: " .. label) + _, err = sock:settimeout(60) + if err ~= nil then + return nil, "settimeout error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Connecting TCP socket for REST Connection: " .. label) + _, err = sock:connect(host, port) + if err ~= nil then + return nil, "Connect error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Set Keepalive for TCP socket for REST Connection: " .. label) + _, err = sock:setoption("keepalive", true) + if err ~= nil then + return nil, "Setoption error: " .. err + end + + if wrap_ssl then + log.debug("utils.labeled_socket_builder(): Creating SSL wrapper for REST Connection: " .. label) + sock, err = ssl.wrap(sock, ssl_config) + if err ~= nil then + return nil, "SSL wrap error: " .. err + end + + log.debug("utils.labeled_socket_builder(): Performing SSL handshake for REST Connection: " .. label) + _, err = sock:dohandshake() + if err ~= nil then + return nil, "Error with SSL handshake: " .. err + end + end + + log.info("utils.labeled_socket_builder(): Successfully created TCP connection: " .. label) + return sock, err + end + + return make_socket +end + +return utils \ No newline at end of file diff --git a/drivers/Aqara/aqara-bath-heater/config.yml b/drivers/Aqara/aqara-bath-heater/config.yml new file mode 100644 index 0000000000..4465c61440 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/config.yml @@ -0,0 +1,6 @@ +name: Aqara Bath Heater +packageKey: aqara-bath-heater +permissions: + zigbee: {} +description: "SmartThings driver for Aqara Bath Heater devices" +vendorSupportInformation: "https://www.aqara.com/en/support/" diff --git a/drivers/Aqara/aqara-bath-heater/fingerprints.yml b/drivers/Aqara/aqara-bath-heater/fingerprints.yml new file mode 100644 index 0000000000..ecef588f02 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/fingerprints.yml @@ -0,0 +1,6 @@ +zigbeeManufacturer: + - id: "Aqara/lumi.bhf_light.acn001" + deviceLabel: Aqara Bath Heater T1 + manufacturer: Aqara + model: lumi.bhf_light.acn001 + deviceProfileName: "aqara-bath-heater" diff --git a/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml new file mode 100644 index 0000000000..2acda35491 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml @@ -0,0 +1,36 @@ +name: aqara-bath-heater +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: colorTemperature + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + - id: fanOscillationMode + version: 1 + - id: fanMode + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: + - preferenceId: stse.nightLightMode + explicit: true + - preferenceId: stse.nightLightStartTime + explicit: true + - preferenceId: stse.nightLightEndTime + explicit: true + - preferenceId: stse.muteBeep + explicit: true + - preferenceId: stse.thermostatCtrl + explicit: true +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-Aqara_Bath_Heater diff --git a/drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua b/drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua new file mode 100644 index 0000000000..90fd6d0655 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local M = {} + +M.CLUSTER_ID = 0xFCC0 +M.MFG_CODE = 0x115F -- Lumi/Aqara manufacturer code + +M.ATTR_AC_CODE = 0x024F +M.ATTR_THERMOSTAT_CTRL_SW = 0x02BE +M.ATTR_DND_BEEP = 0x0256 +M.ATTR_DND_TIME = 0x0257 +M.ATTR_NIGHT_LIGHT = 0x0518 + +return M diff --git a/drivers/Aqara/aqara-bath-heater/src/init.lua b/drivers/Aqara/aqara-bath-heater/src/init.lua new file mode 100644 index 0000000000..62007b537c --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/init.lua @@ -0,0 +1,538 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local capabilities = require "st.capabilities" +local ZigbeeDriver = require "st.zigbee" +local defaults = require "st.zigbee.defaults" +local cluster_base = require "st.zigbee.cluster_base" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" + +local aqara = require "aqara_cluster" + +local OnOff = zcl_clusters.OnOff +local Level = zcl_clusters.Level +local ColorControl = zcl_clusters.ColorControl + +-- Aqara manufacturer-specific preference keys +local nightLightMode = "stse.nightLightMode" +local nightLightEndTime = "stse.nightLightEndTime" +local nightLightStartTime = "stse.nightLightStartTime" +local muteBeep = "stse.muteBeep" +local thermostatCtrl = "stse.thermostatCtrl" + +-- AC code field values (see send_ac_code for the bit layout) +local PWR = { OFF = 0x0, ON = 0x1 } +local MODE = { HEAT = 0x0, DRYAIR = 0x3, COOL = 0x4, FANONLY = 0x5, INVALID = 0xF } +local FAN_LOW = 0x0 +local FAN_MID = 0x1 +local FAN_HIGH = 0x2 +local FAN_INVALID = 0xF +local SWING_ON = 0x0 +local SWING_OFF = 0x1 + +-- SmartThings fanMode capability values +local SPEED = { + LOW = "low", + MEDIUM = "medium", + HIGH = "high", +} +local MODE_TO_FAN = { [SPEED.LOW] = FAN_LOW, [SPEED.MEDIUM] = FAN_MID, [SPEED.HIGH] = FAN_HIGH } +local FAN_TO_MODE = { [FAN_LOW] = SPEED.LOW, [FAN_MID] = SPEED.MEDIUM, [FAN_HIGH] = SPEED.HIGH } + +-- SmartThings fanOscillationMode capability values +local OSC = { + SWING = "swing", + FIXED = "fixed", +} +local ST_FAN_TO_SWING = { + [OSC.SWING] = SWING_ON, + [OSC.FIXED] = SWING_OFF, +} + +-- SmartThings thermostatMode capability values +local ST_MODE = { + OFF = "off", + HEAT = "heat", + DRYAIR = "dryair", + COOL = "cool", + FANONLY = "fanonly", +} + +-- SmartThings thermostatMode -> AC parameters +local ST_TO_AC = { + [ST_MODE.OFF] = { pwr = PWR.OFF, mode = MODE.INVALID, fan = FAN_INVALID }, + [ST_MODE.HEAT] = { pwr = PWR.ON, mode = MODE.HEAT, fan = FAN_MID }, + [ST_MODE.DRYAIR] = { pwr = PWR.ON, mode = MODE.DRYAIR, fan = FAN_MID }, + [ST_MODE.COOL] = { pwr = PWR.ON, mode = MODE.COOL, fan = FAN_MID }, + [ST_MODE.FANONLY] = { pwr = PWR.ON, mode = MODE.FANONLY, fan = FAN_MID }, +} + +-- AC mode bits -> SmartThings thermostatMode +local AC_MODE_TO_ST = { + [0x0] = ST_MODE.HEAT, + [0x3] = ST_MODE.DRYAIR, + [0x4] = ST_MODE.COOL, + [0x5] = ST_MODE.FANONLY, +} + +local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end + +-- Encode and send the 64-bit AC control code as an Aqara manufacturer attribute. +-- A nibble of 0xF means "no change"; the default 0xFFFFFFFFFFFFFFFF leaves +-- every field untouched so callers only need to set what they want to change. +-- pwr : bits31-28 0=off 1=on +-- mode : bits27-24 0=heat 3=dryair 4=cool 5=fanonly +-- fan : bits23-20 0=low 1=mid 2=high 3=auto +-- swing : bits17-16 0=swing 1=fixed +-- setpoint : bits63-48 Celsius x 100 +local function send_ac_code(device, params) + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + + if params.setpoint ~= nil then + local sp_raw = math.floor(clamp(params.setpoint, 16, 45) * 100) & 0xFFFF + hi32 = (sp_raw << 16) | 0xFFFF + end + + if params.pwr ~= nil then + lo32 = (lo32 & 0x0FFFFFFF) | ((params.pwr & 0xF) << 28) + end + + if params.mode ~= nil then + lo32 = (lo32 & 0xF0FFFFFF) | ((params.mode & 0xF) << 24) + end + + if params.fan ~= nil then + lo32 = (lo32 & 0xFF0FFFFF) | ((params.fan & 0xF) << 20) + end + + if params.swing ~= nil then + lo32 = (lo32 & 0xFFFCFFFF) | ((params.swing & 0x3) << 16) + end + + local bytes = string.char( + (hi32 >> 24) & 0xFF, + (hi32 >> 16) & 0xFF, + (hi32 >> 8) & 0xFF, + hi32 & 0xFF, + (lo32 >> 24) & 0xFF, + (lo32 >> 16) & 0xFF, + (lo32 >> 8) & 0xFF, + lo32 & 0xFF + ) + + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_AC_CODE, aqara.MFG_CODE, + data_types.Uint64, bytes + )) +end + +-- Per-mode state persistence: remember the last swing/fan used in each +-- thermostat mode so they can be restored when the user returns to it. +-- Setpoint is shared across modes and read directly from the capability state. +local FIELD = { + SWING = "swing", + FAN_MODE = "fan_mode", +} + +-- Fields tracked per mode (modes not listed here have no per-mode state). +local MODE_FIELDS = { + [ST_MODE.HEAT] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.COOL] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.DRYAIR] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.FANONLY] = { FIELD.FAN_MODE }, +} + +-- Initial values when no saved state exists yet for a mode. +local MODE_DEFAULTS = { + [ST_MODE.HEAT] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.COOL] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.DRYAIR] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.FANONLY] = { fan_mode = SPEED.MEDIUM }, +} + +local function save_mode_state(device, mode, field, value) + device:set_field("mode_state." .. mode .. "." .. field, value, { persist = true }) +end + +local function load_mode_state(device, mode, field) + return device:get_field("mode_state." .. mode .. "." .. field) +end + +local function save_current_mode_field(device, field, value) + local mode = device:get_field("thermostat_mode") or ST_MODE.OFF + local fields = MODE_FIELDS[mode] + if fields then + for _, f in ipairs(fields) do + if f == field then + save_mode_state(device, mode, field, value) + return + end + end + end +end + +-- Capture the current capability state for each field tracked by the given +-- mode. Used right before switching modes so any in-flight changes (e.g., a +-- setpoint set via the UI but not yet confirmed by the device) are preserved. +local function snapshot_mode_state(device, mode) + local fields = MODE_FIELDS[mode] + if not fields then return end + + for _, field in ipairs(fields) do + local v + if field == FIELD.SWING then + v = device:get_latest_state("main", + capabilities.fanOscillationMode.ID, + capabilities.fanOscillationMode.fanOscillationMode.NAME) + elseif field == FIELD.FAN_MODE then + v = device:get_latest_state("main", + capabilities.fanMode.ID, + capabilities.fanMode.fanMode.NAME) + end + if v ~= nil then + save_mode_state(device, mode, field, v) + end + end +end + +-- Re-emit saved values for the entered mode and push them to the AC in a +-- single batched code, falling back to MODE_DEFAULTS on first use. +local function restore_mode_state(device, st_mode) + local fields = MODE_FIELDS[st_mode] + if not fields then return end + + local mode_defaults = MODE_DEFAULTS[st_mode] or {} + local swing, fan = nil, nil + + for _, field in ipairs(fields) do + if field == FIELD.SWING then + local v = load_mode_state(device, st_mode, FIELD.SWING) or mode_defaults.swing + if v ~= nil then + swing = ST_FAN_TO_SWING[v] + device:set_field("fan_mode", v) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(v)) + end + elseif field == FIELD.FAN_MODE then + local v = load_mode_state(device, st_mode, FIELD.FAN_MODE) or mode_defaults.fan_mode + if v ~= nil then + fan = MODE_TO_FAN[v] + device:set_field("fan_mode_ac", fan) + device:emit_event(capabilities.fanMode.fanMode(v)) + end + end + end + + if swing ~= nil or fan ~= nil then + send_ac_code(device, { swing = swing, fan = fan }) + end +end + + +-- Capability handlers +local function handle_thermostat_mode(driver, device, cmd) + local st_mode = cmd.args.mode + local ac = ST_TO_AC[st_mode] + if not ac then return end + + local prev_mode = device:get_field("thermostat_mode") + if prev_mode and prev_mode ~= st_mode then + snapshot_mode_state(device, prev_mode) + end + + local pwr = (st_mode == ST_MODE.OFF) and PWR.OFF or PWR.ON + -- Setpoint is shared across modes; on entry to HEAT, push the last value + -- the user set so the device matches what the UI is currently showing. + local setpoint = nil + if st_mode == ST_MODE.HEAT then + local state = device:get_latest_state("main", + capabilities.thermostatHeatingSetpoint.ID, + capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME) + if state ~= nil then + setpoint = clamp(state, 16, 45) + end + end + + send_ac_code(device, { pwr = pwr, mode = ac.mode, setpoint = setpoint }) + device:set_field("thermostat_mode", st_mode) + device:emit_event(capabilities.thermostatMode.thermostatMode(st_mode)) + restore_mode_state(device, st_mode) + + if st_mode ~= ST_MODE.OFF then + device:set_field("pending_on_mode", st_mode) + else + device:set_field("pending_on_mode", nil) + end +end + +local function handle_heating_setpoint(driver, device, cmd) + local temp_c = clamp(cmd.args.setpoint, 16, 45) + device:set_field("heating_setpoint", temp_c) + + local cur = device:get_field("thermostat_mode") or ST_MODE.OFF + if cur == ST_MODE.HEAT then + send_ac_code(device, { setpoint = temp_c }) + end +end + +local function handle_fan_oscillation_mode(driver, device, cmd) + local st_fan = cmd.args.fanOscillationMode + local swing = ST_FAN_TO_SWING[st_fan] or SWING_ON + + device:set_field("fan_mode", st_fan) + send_ac_code(device, { swing = swing }) +end + +local function handle_fan_mode(driver, device, cmd) + local fan_mode = cmd.args.fanMode + local fan = MODE_TO_FAN[fan_mode] or FAN_MID + device:set_field("fan_mode_ac", fan) + send_ac_code(device, { fan = fan }) +end + +local function handle_refresh(driver, device) + device:send(OnOff.attributes.OnOff:read(device)) + device:send(Level.attributes.CurrentLevel:read(device)) + device:send(ColorControl.attributes.ColorTemperatureMireds:read(device)) +end + +-- Zigbee attribute handlers +-- Decode the AC code reported by the device, emit matching capability events, +-- and persist the per-mode state so values are restored when the user returns +-- to that mode. +local function ac_code_attr_handler(driver, device, value, zb_rx) + local raw = value.value + local hi32, lo32 + + -- The attribute is a Uint64 but may arrive either as a raw integer or as + -- the 8-byte big-endian payload depending on the runtime path. + if type(raw) == "string" then + local b = { string.byte(raw, 1, 8) } + hi32 = ((b[1] or 0) << 24) | ((b[2] or 0) << 16) | ((b[3] or 0) << 8) | (b[4] or 0) + lo32 = ((b[5] or 0) << 24) | ((b[6] or 0) << 16) | ((b[7] or 0) << 8) | (b[8] or 0) + else + hi32 = (raw >> 32) & 0xFFFFFFFF + lo32 = raw & 0xFFFFFFFF + end + + local pwr = (lo32 >> 28) & 0xF + local mode = (lo32 >> 24) & 0xF + local fan_set = (lo32 >> 20) & 0xF + local b15_8 = (lo32 >> 8) & 0xFF + local b7_0 = lo32 & 0xFF + local bits7_2 = (b7_0 >> 2) & 0x3F + + -- The setpoint nibbles are only trustworthy when the surrounding sentinel + -- bytes match this pattern; otherwise the device is reporting "no change". + local hi_valid = (b15_8 >= 0xFE) and (bits7_2 == 63) + local setpoint_raw = (hi32 >> 16) & 0xFFFF + + if hi_valid and setpoint_raw ~= 0xFFFF then + local sp = setpoint_raw / 100.0 + device:set_field("heating_setpoint", sp) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint( + { value = sp, unit = "C" } + )) + end + + -- fan speed (bits23-20): 0=low, 1=mid, 2=high; 3=auto and 0xF are ignored. + if fan_set <= 2 then + local fan_mode = FAN_TO_MODE[fan_set] or SPEED.MEDIUM + device:set_field("fan_mode_ac", fan_set) + save_current_mode_field(device, FIELD.FAN_MODE, fan_mode) + device:emit_event(capabilities.fanMode.fanMode(fan_mode)) + end + + -- swing mode (bits17-16): 0=swing, 1=fixed; other values are ignored. + local swing_bit = (lo32 >> 16) & 0x3 + if swing_bit == 0 then + device:set_field("fan_mode", OSC.SWING) + save_current_mode_field(device, FIELD.SWING, OSC.SWING) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.SWING)) + elseif swing_bit == 1 then + device:set_field("fan_mode", OSC.FIXED) + save_current_mode_field(device, FIELD.SWING, OSC.FIXED) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.FIXED)) + end + + -- 0xF in the pwr nibble is the "no change" sentinel; mode bits are unreliable. + if pwr == 0xF then return end + + local st_mode + if pwr == 0x0 then + st_mode = ST_MODE.OFF + else + st_mode = AC_MODE_TO_ST[mode] or ST_MODE.HEAT + end + + -- Suppress a transient "off" report that arrives between a mode change + -- request and the device confirming the new mode. + local pending = device:get_field("pending_on_mode") + if st_mode ~= ST_MODE.OFF then + device:set_field("pending_on_mode", nil) + else + if pending ~= nil then return end + end + + local current = device:get_field("thermostat_mode") + if current ~= st_mode then + device:set_field("thermostat_mode", st_mode) + device:emit_event(capabilities.thermostatMode.thermostatMode(st_mode)) + end +end + +local SUPPORTED_THERMOSTAT_MODES = { + capabilities.thermostatMode.thermostatMode.off.NAME, + capabilities.thermostatMode.thermostatMode.heat.NAME, + capabilities.thermostatMode.thermostatMode.dryair.NAME, + capabilities.thermostatMode.thermostatMode.cool.NAME, + capabilities.thermostatMode.thermostatMode.fanonly.NAME +} + +local SUPPORTED_FAN_MODES = { + capabilities.fanOscillationMode.fanOscillationMode.swing.NAME, + capabilities.fanOscillationMode.fanOscillationMode.fixed.NAME +} + +local SUPPORTED_SPEED_MODES = { SPEED.LOW, SPEED.MEDIUM, SPEED.HIGH } + +-- Lifecycle handlers +local function device_init(driver, device) + device:emit_event(capabilities.thermostatMode.supportedThermostatModes( + SUPPORTED_THERMOSTAT_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.fanOscillationMode.supportedFanOscillationModes( + SUPPORTED_FAN_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.fanMode.supportedFanModes( + SUPPORTED_SPEED_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpointRange( + { value = { minimum = 16, maximum = 45, step = 1 }, unit = "C" } + )) + handle_refresh(driver, device) +end + +local function device_added(driver, device) + if device:get_latest_state("main", capabilities.thermostatHeatingSetpoint.ID, + capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME) == nil then + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint( + { value = 25, unit = "C" } + )) + send_ac_code(device, { setpoint = 25 }) + end + if device:get_latest_state("main", capabilities.fanMode.ID, + capabilities.fanMode.fanMode.NAME) == nil then + device:emit_event(capabilities.fanMode.fanMode(SPEED.MEDIUM)) + end + if device:get_latest_state("main", capabilities.fanOscillationMode.ID, + capabilities.fanOscillationMode.fanOscillationMode.NAME) == nil then + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.SWING)) + end + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) +end + +local function send_night_light(device, new) + local start_time = (tonumber(new[nightLightStartTime]) * 60) & 0xFFF + local end_time = (tonumber(new[nightLightEndTime]) * 60) & 0xFFF + local on_val = (end_time << 12) | start_time + local val = new[nightLightMode] and on_val or (on_val + 1) + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_NIGHT_LIGHT, + aqara.MFG_CODE, data_types.Uint32, val)) +end + +local function info_changed(driver, device, event, args) + if args.old_st_store.preferences == nil then return end + + local old = args.old_st_store.preferences + local new = device.preferences + + -- Night-light: re-send when the on/off toggle flips, or when the schedule + -- changes while the feature is enabled. + local mode_changed = old[nightLightMode] ~= new[nightLightMode] + local time_changed = + old[nightLightEndTime] ~= new[nightLightEndTime] or + old[nightLightStartTime] ~= new[nightLightStartTime] + if mode_changed then + send_night_light(device, new) + elseif time_changed and new[nightLightMode] == true then + send_night_light(device, new) + end + + -- Mute beep ("do not disturb"). On first init we always push the value so + -- the device matches the preference even if it was changed before pairing. + if old[muteBeep] ~= new[muteBeep] or device:get_field("inited") == nil then + local val = new[muteBeep] and 1 or 0 + device:set_field("inited", true) + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_DND_BEEP, + aqara.MFG_CODE, data_types.Uint8, val)) + -- When un-muted, configure the DND window to span 24h (00:18 - 00:18). + if val == 0 then + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_DND_TIME, + aqara.MFG_CODE, data_types.Uint32, 0x00120012)) + end + end + + -- Constant-temperature thermostat control switch. + if old[thermostatCtrl] ~= new[thermostatCtrl] then + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_THERMOSTAT_CTRL_SW, + aqara.MFG_CODE, data_types.Uint8, new[thermostatCtrl] and 1 or 0)) + end +end + +local aqara_bathroom_heater_driver_template = { + supported_capabilities = { + capabilities.switch, + capabilities.switchLevel, + capabilities.colorTemperature, + capabilities.thermostatMode, + capabilities.thermostatHeatingSetpoint, + capabilities.fanOscillationMode, + capabilities.fanMode + }, + + capability_handlers = { + [capabilities.thermostatMode.ID] = { + [capabilities.thermostatMode.commands.setThermostatMode.NAME] = handle_thermostat_mode, + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + [capabilities.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = handle_heating_setpoint, + }, + [capabilities.fanOscillationMode.ID] = { + [capabilities.fanOscillationMode.commands.setFanOscillationMode.NAME] = handle_fan_oscillation_mode, + }, + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = handle_fan_mode, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = handle_refresh, + }, + }, + + zigbee_handlers = { + attr = { + [aqara.CLUSTER_ID] = { + [aqara.ATTR_AC_CODE] = ac_code_attr_handler, + }, + }, + }, + health_check = false, + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed, + }, +} + +defaults.register_for_default_handlers( + aqara_bathroom_heater_driver_template, + aqara_bathroom_heater_driver_template.supported_capabilities, + { native_capability_cmds_enabled = true, native_capability_attrs_enabled = true } +) + +local aqara_bathroom_heater_driver = ZigbeeDriver("aqara-bathroom-heater-t1", aqara_bathroom_heater_driver_template) +aqara_bathroom_heater_driver:run() diff --git a/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua new file mode 100644 index 0000000000..8dfaa66974 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua @@ -0,0 +1,854 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Consolidated test cases for the Aqara Bath Heater T1 SmartThings Edge driver. +-- +-- IMPORTANT: The test framework fires an "init" lifecycle event before every +-- test (the driver must complete its startup sequence before the test body +-- can run). Because `device_init` emits multiple capability events +-- (supported*, range) and issues three Zigbee attribute reads, `test_init` +-- pre-registers those expectations BEFORE calling `add_test_device(...)`, so +-- each individual test body can ignore the init emissions and focus only on +-- its own test-specific expectations. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local clusters = require "st.zigbee.zcl.clusters" + +local OnOff = clusters.OnOff +local Level = clusters.Level +local ColorControl = clusters.ColorControl + +local AQARA_CLUSTER_ID = 0xFCC0 +local AQARA_MFG_CODE = 0x115F +local ATTR_AC_CODE = 0x024F +local ATTR_THERMOSTAT_CTRL_SW = 0x02BE +local ATTR_DND_BEEP = 0x0256 +local ATTR_DND_TIME = 0x0257 +local ATTR_NIGHT_LIGHT = 0x0518 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("aqara-bath-heater.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Aqara", + model = "lumi.bhf_light.acn001", + server_clusters = { 0x0006, 0x0008, 0x0300, 0xFCC0 } + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes( + { "off", "heat", "dryair", "cool", "fanonly" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.supportedFanOscillationModes( + { "swing", "fixed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.supportedFanModes( + { "low", "medium", "high" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpointRange( + { value = { minimum = 16, maximum = 45, step = 1 }, unit = "C" }))) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- ---------------------------------------------------------------------------- +-- Helpers +-- ---------------------------------------------------------------------------- + +-- Build the 8-byte big-endian raw payload for the Aqara AC-code Uint64 attr. +local function ac_code_bytes(hi32, lo32) + return string.char( + (hi32 >> 24) & 0xFF, + (hi32 >> 16) & 0xFF, + (hi32 >> 8) & 0xFF, + hi32 & 0xFF, + (lo32 >> 24) & 0xFF, + (lo32 >> 16) & 0xFF, + (lo32 >> 8) & 0xFF, + lo32 & 0xFF + ) +end + +local function expect_ac_code_send(hi32, lo32) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_AC_CODE, AQARA_MFG_CODE, + data_types.Uint64, ac_code_bytes(hi32, lo32)) }) +end + +local function build_ac_code_report(hi32, lo32) + return zigbee_test_utils.build_attribute_report(mock_device, AQARA_CLUSTER_ID, + { { ATTR_AC_CODE, data_types.Uint64.ID, ac_code_bytes(hi32, lo32) } }) +end + +-- ============================================================================ +-- 1. CAPABILITY COMMAND HANDLERS +-- ============================================================================ +-- +-- switch / switchLevel / colorTemperature are handled by the SmartThings +-- default zigbee handlers (registered via defaults.register_for_default_handlers), +-- so their behavior is covered by the framework's own tests and is not +-- re-tested here. + +-- thermostatMode.setThermostatMode -------------------------------------------- + +test.register_coroutine_test( + "Capability thermostatMode 'off' should send AC off code and emit event", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "off" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x0 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0xF << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("off"))) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'heat' should send AC code and restore defaults", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "heat" } + } }) + + -- No prior heatingSetpoint latest state, so the first AC code carries + -- only pwr=1 and mode=0 (no setpoint nibble set). + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'cool' should send AC code (mode=4)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "cool" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x4 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("cool"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'dryair' should send AC code (mode=3)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "dryair" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x3 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("dryair"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'fanonly' should send AC code (mode=5) and restore only fan", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "fanonly" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x5 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("fanonly"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = (0xFFFFFFFF & 0xFF0FFFFF) | (0x1 << 20) + expect_ac_code_send(r_hi, r_lo) + end +) + +-- NOTE: setThermostatMode("unsupported_mode") would hit the driver's +-- `if not ac then return end` guard, but the capability framework validates +-- `mode` against its enum and rejects unknown values before the handler runs. + +-- thermostatHeatingSetpoint.setHeatingSetpoint -------------------------------- + +test.register_coroutine_test( + "Capability heatingSetpoint 28 in non-heat mode produces no observable output", + function() + -- Outside heat mode the handler only stashes the value in a field; the + -- capability event will fire later via the AC-code attribute report from + -- the device, not from this command path. + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 28 } + } }) + end +) + +-- NOTE: setHeatingSetpoint(10) / (60) would exercise the clamp(..., 16, 45), +-- but the profile constrains the setpoint to [16, 45] so framework validation +-- rejects those values. The same clamp is exercised via restore_mode_state +-- ("setThermostatMode heat should restore saved setpoint/swing/fan from +-- mode_state") below. + +test.register_coroutine_test( + "Capability heatingSetpoint in heat mode should send AC code with setpoint", + function() + mock_device:set_field("thermostat_mode", "heat") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 30 } + } }) + + local hi32 = ((3000 & 0xFFFF) << 16) | (0xFFFFFFFF & 0xFFFF) + local lo32 = 0xFFFFFFFF + expect_ac_code_send(hi32, lo32) + end +) + +-- fanOscillationMode.setFanOscillationMode ------------------------------------ + +test.register_coroutine_test( + "Capability fanOscillationMode 'swing' sends AC code with swing bits=0", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "swing" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(hi32, lo32) + end +) + +test.register_coroutine_test( + "Capability fanOscillationMode 'fixed' sends AC code with swing bits=1", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "fixed" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0xFFFCFFFF) | (0x1 << 16) + expect_ac_code_send(hi32, lo32) + end +) + +-- fanMode.setFanMode ---------------------------------------------------------- + +test.register_coroutine_test( + "Capability fanMode 'low' sends AC code with fan bits=0", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "low" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x0 << 20)) + end +) + +test.register_coroutine_test( + "Capability fanMode 'medium' sends AC code with fan bits=1", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "medium" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x1 << 20)) + end +) + +test.register_coroutine_test( + "Capability fanMode 'high' sends AC code with fan bits=2", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "high" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x2 << 20)) + end +) + +-- NOTE: setFanMode("auto") would exercise the `MODE_TO_FAN[fan_mode] or +-- FAN_MID` fallback, but the profile's enabledValues restricts fanMode to +-- {"low","medium","high"}, so "auto" is rejected by framework validation. + +-- refresh --------------------------------------------------------------------- + +test.register_coroutine_test( + "Capability refresh should read OnOff, Level and ColorTemperature", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end +) + +-- ============================================================================ +-- 2. ZIGBEE ATTRIBUTE HANDLERS +-- ============================================================================ +-- +-- OnOff / CurrentLevel / ColorTemperatureMireds attribute reports are handled +-- by the SmartThings default zigbee handlers and emit their corresponding +-- capability events automatically, so they are not re-tested here. + +-- Aqara AC code (0xFCC0 / 0x024F) --------------------------------------------- + +test.register_coroutine_test( + "AC code report: heat + medium fan + swing + setpoint 25.00 should emit all events", + function() + local hi32 = (2500 << 16) | 0xFEFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: pwr=0 (off) should emit thermostatMode 'off'", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x0 << 28) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x1 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("fixed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("off"))) + end +) + +test.register_coroutine_test( + "AC code report: pwr=0xF (invalid) should skip thermostatMode update", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0xFF0FFFFF) | (0x0 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("low"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + end +) + +test.register_coroutine_test( + "AC code report: fan=2 (high) and mode=4 (cool)", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x4 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x2 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("high"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("cool"))) + end +) + +test.register_coroutine_test( + "AC code report: unknown mode bits should fall back to 'heat'", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x7 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: fanonly mode (5)", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x5 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("fanonly"))) + end +) + +test.register_coroutine_test( + "AC code report: pending_on_mode + incoming off should NOT overwrite mode", + function() + mock_device:set_field("pending_on_mode", "heat") + + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x0 << 28) -- pwr=0 (off) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) -- fan=1 (medium) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) -- swing bits=00 (swing) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + -- thermostatMode NOT emitted: pwr=0 → st_mode="off", but pending_on_mode="heat" + -- causes early return from the handler. + end +) + +test.register_coroutine_test( + "AC code report: setpoint 0xFFFF (invalid marker) should skip setpoint emit", + function() + local hi32 = (0xFFFF << 16) | 0xFEFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: invalid frame (b15_8 < 0xFE) should skip setpoint emit", + function() + -- hi32 carries a valid-looking setpoint raw (2800 = 28.00°C); the "invalid" + -- frame marker lives in lo32 bits 15-8 (b15_8). Clearing them to 0x00 + -- makes hi_valid = false, so the setpoint emit MUST be suppressed even + -- though setpoint_raw != 0xFFFF. + local hi32 = (2800 << 16) | 0x0000 + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) -- pwr=1 (on) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) -- mode=0 (heat) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) -- fan=1 (medium) + lo32 = lo32 & 0xFFFF00FF -- b15_8 = 0x00 → frame invalid + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + -- Setpoint NOT emitted because hi_valid=false. + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +-- ============================================================================ +-- 3. LIFECYCLE HANDLERS +-- ============================================================================ + +test.register_coroutine_test( + "Lifecycle init should emit supported* / range events and read attributes", + function() + -- This test only verifies the emissions fired by the automatic init; + -- those expectations are already registered in test_init(). + end +) + +test.register_coroutine_test( + "Lifecycle added should emit defaults (setpoint=25, fan=medium, swing=swing) + AC code", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25, unit = "C" }))) + + local hi32 = ((2500 & 0xFFFF) << 16) | (0xFFFFFFFF & 0xFFFF) + expect_ac_code_send(hi32, 0xFFFFFFFF) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} }))) + end +) + +-- info_changed helpers -------------------------------------------------------- + +local function expected_night_light_value(start_h, end_h, enabled) + local start_time = (start_h * 60) & 0xFFF + local end_time = (end_h * 60) & 0xFFF + local on_val = (end_time << 12) | start_time + return enabled and on_val or (on_val + 1) +end + +test.register_coroutine_test( + "infoChanged nightLightMode on (first init) should send night-light + DND-beep + DND-time", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9, + ["stse.muteBeep"] = false + } + })) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 0) }) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_TIME, AQARA_MFG_CODE, + data_types.Uint32, 0x00120012) }) + end +) + +test.register_coroutine_test( + "infoChanged nightLightMode off should send night-light disabled value", + function() + mock_device:set_field("inited", true) + + -- The profile default is nightLightMode=false, so passing `false` again + -- would produce old==new and the handler's `mode_changed` branch would + -- not fire. First flip it ON (consuming the resulting "enabled" write), + -- then flip it OFF — which is the transition this test actually covers. + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = false, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, false)) }) + end +) + +test.register_coroutine_test( + "infoChanged night-light time changed while enabled should resend night-light", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 22, + ["stse.nightLightEndTime"] = 8 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(22, 8, true)) }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + end +) + +test.register_coroutine_test( + "infoChanged muteBeep toggled on should write DND-beep=1", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.muteBeep"] = true + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 1) }) + end +) + +test.register_coroutine_test( + "infoChanged thermostatCtrl toggled off should write 0", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.thermostatCtrl"] = false, + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_THERMOSTAT_CTRL_SW, AQARA_MFG_CODE, + data_types.Uint8, 0) }) + end +) + +test.register_coroutine_test( + "infoChanged when not yet initialized should trigger initialization", + function() + -- mock_device:set_field("inited", "") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.muteBeep"] = true + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 1) }) + end +) + +-- ============================================================================ +-- 4. EDGE CASES / BRANCH COVERAGE +-- ============================================================================ + +test.register_coroutine_test( + "setHeatingSetpoint while mode is 'off' produces no observable output", + function() + mock_device:set_field("thermostat_mode", "off") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 22 } + } }) + end +) + +test.register_coroutine_test( + "setFanOscillationMode while mode is 'fanonly' still sends AC code", + function() + mock_device:set_field("thermostat_mode", "fanonly") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "fixed" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFFFCFFFF) | (0x1 << 16)) + end +) + +test.register_coroutine_test( + "setThermostatMode heat should restore saved swing/fan from mode_state", + function() + mock_device:set_field("mode_state.heat.swing", "fixed") + mock_device:set_field("mode_state.heat.fan_mode", "high") + + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "heat" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("fixed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("high"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x2 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x1 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +-- NOTE: The `if args.old_st_store.preferences == nil then return end` early +-- return in info_changed is defensive code that only fires on a brand-new +-- device. It cannot be reliably reproduced in the SmartThings integration +-- test framework because the mock_device is always built from the profile's +-- preference section (st_store.preferences is always a table), and manually +-- constructing a lifecycle event with preferences=nil is normalized away by +-- the lifecycle dispatcher before the handler sees it. + +test.run_registered_tests() diff --git a/drivers/Aqara/aqara-lock/src/credential_utils.lua b/drivers/Aqara/aqara-lock/src/credential_utils.lua index 5725d5f55a..df0e904886 100644 --- a/drivers/Aqara/aqara-lock/src/credential_utils.lua +++ b/drivers/Aqara/aqara-lock/src/credential_utils.lua @@ -6,6 +6,31 @@ local lockCredentialInfo = capabilities["stse.lockCredentialInfo"] local credential_utils = {} local HOST_COUNT = "__host_count" +local PERSIST_DATA = "__persist_area" + +credential_utils.eventResource = function(table) + local credentialResource = {} + for key, value in pairs(table) do + credentialResource[key] = value + end + return credentialResource +end + +credential_utils.backup_data = function(device) -- Back up data the persistent + local credentialInfoTable = utils.deep_copy(device:get_latest_state("main", lockCredentialInfo.ID, + lockCredentialInfo.credentialInfo.NAME, {})) + device:set_field(PERSIST_DATA, credentialInfoTable, { persist = true }) +end + +credential_utils.sync = function(driver, device) + local table = device:get_field(PERSIST_DATA) or nil + if table ~= nil then + device:emit_event(lockCredentialInfo.credentialInfo(credential_utils.eventResource(table), + { visibility = { displayed = false } })) + else + credential_utils.backup_data(device) + end +end credential_utils.save_data = function(driver) driver.datastore:save() @@ -28,6 +53,7 @@ credential_utils.update_remote_control_status = function(driver, device, added) end device:set_field(HOST_COUNT, host_cnt, { persist = true }) + credential_utils.backup_data(device) credential_utils.save_data(driver) end @@ -38,6 +64,7 @@ credential_utils.sync_all_credential_info = function(driver, device, command) end end device:emit_event(lockCredentialInfo.credentialInfo(command.args.credentialInfo, { visibility = { displayed = false } })) + credential_utils.backup_data(device) credential_utils.save_data(driver) end @@ -73,6 +100,7 @@ credential_utils.upsert_credential_info = function(driver, device, command) end device:emit_event(lockCredentialInfo.credentialInfo(credentialInfoTable, { visibility = { displayed = false } })) + credential_utils.backup_data(device) credential_utils.save_data(driver) end @@ -95,6 +123,7 @@ credential_utils.delete_user = function(driver, device, command) end device:emit_event(lockCredentialInfo.credentialInfo(credentialInfoTable, { visibility = { displayed = false } })) + credential_utils.backup_data(device) credential_utils.save_data(driver) end @@ -116,6 +145,7 @@ credential_utils.delete_credential = function(driver, device, command) end device:emit_event(lockCredentialInfo.credentialInfo(credentialInfoTable, { visibility = { displayed = false } })) + credential_utils.backup_data(device) credential_utils.save_data(driver) end diff --git a/drivers/Aqara/aqara-lock/src/init.lua b/drivers/Aqara/aqara-lock/src/init.lua index e9eb32c9b4..9afe6bb77e 100644 --- a/drivers/Aqara/aqara-lock/src/init.lua +++ b/drivers/Aqara/aqara-lock/src/init.lua @@ -86,6 +86,7 @@ local function device_init(self, device) end device:emit_event(capabilities.battery.quantity(battery_quantity)) device:emit_event(capabilities.batteryLevel.quantity(battery_quantity)) + credential_utils.sync(self, device) end local function device_added(self, device) diff --git a/drivers/Aqara/aqara-lock/src/test/test_aqara_lock_L100.lua b/drivers/Aqara/aqara-lock/src/test/test_aqara_lock_L100.lua new file mode 100644 index 0000000000..3801183490 --- /dev/null +++ b/drivers/Aqara/aqara-lock/src/test/test_aqara_lock_L100.lua @@ -0,0 +1,77 @@ +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local remoteControlStatus = capabilities.remoteControlStatus +local antiLockStatus = capabilities["stse.antiLockStatus"] +test.add_package_capability("antiLockStatus.yaml") +local lockCredentialInfo = capabilities["stse.lockCredentialInfo"] +test.add_package_capability("lockCredentialInfo.yaml") +local lockAlarm = capabilities["lockAlarm"] +test.add_package_capability("lockAlarm.yaml") +local Battery = capabilities.battery +local BatteryLevel = capabilities.batteryLevel +local Lock = capabilities.lock + +local PRI_CLU = 0xFCC0 + +local HOST_COUNT = "__host_count" +local PERSIST_DATA = "__persist_area" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-lock-battery.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Lumi", + model = "aqara.lock.akr001", + server_clusters = { PRI_CLU } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + local SUPPORTED_ALARM_VALUES = { "damaged", "forcedOpeningAttempt", "unableToLockTheDoor", "notClosedForALongTime", + "highTemperature", "attemptsExceeded" } + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + lockAlarm.supportedAlarmValues(SUPPORTED_ALARM_VALUES, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + Lock.supportedUnlockDirections({"fromInside", "fromOutside"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.type("AA"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.type("AA"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.quantity(6))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.quantity(6))) + local credentialInfoData = { + { credentialId = 1, credentialType = "keypad", userId = "1", userLabel = "june", userType = "host" } + } + mock_device:set_field(PERSIST_DATA, credentialInfoData, { persist = true }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + lockCredentialInfo.credentialInfo(credentialInfoData, { visibility = { displayed = false } }))) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle - only regular user", + function() + mock_device:set_field(HOST_COUNT, 1, { persist = true }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + remoteControlStatus.remoteControlEnabled('true', { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Battery.battery(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BatteryLevel.battery("normal"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + lockAlarm.alarm.clear({ visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + antiLockStatus.antiLockStatus('unknown', { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", Lock.lock("locked"))) + end +) + +test.run_registered_tests() diff --git a/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua index 7c72f2d81a..9b64844821 100644 --- a/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua +++ b/drivers/Aqara/aqara-presence-sensor/src/lunchbox/sse/eventsource.lua @@ -365,18 +365,26 @@ local function open_action(source) local recv_as_num = tonumber(recv, 16) if recv_as_num ~= nil then - recv, err, partial = source._sock:receive(recv_as_num) - if err then - if err == "timeout" or err == "wantread" then - return - else - --- real error, close the connection. - if source._sock ~= nil then - source._sock:close() - source._sock = nil + -- A chunk size of 0 signals the end of the chunked transfer. Some + -- versions of the underlying socket library will raise a hard error + -- (rather than returning a graceful nil/"closed" pair) if we ask to + -- receive 0 bytes, so avoid calling receive() in that case entirely. + if recv_as_num == 0 then + recv = "" + else + recv, err, partial = source._sock:receive(recv_as_num) + if err then + if err == "timeout" or err == "wantread" then + return + else + --- real error, close the connection. + if source._sock ~= nil then + source._sock:close() + source._sock = nil + end + source.ready_state = EventSource.ReadyStates.CLOSED + return nil, err, partial end - source.ready_state = EventSource.ReadyStates.CLOSED - return nil, err, partial end end local _, err, partial = source._sock:receive('*l') -- clear the final line 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/jbl/src/init.lua b/drivers/SmartThings/jbl/src/init.lua index f422ecf84c..830a98a181 100644 --- a/drivers/SmartThings/jbl/src/init.lua +++ b/drivers/SmartThings/jbl/src/init.lua @@ -82,6 +82,11 @@ local function update_connection(driver, device, device_ip, device_info) local conn_info = driver.discovery_helper.get_connection_info(driver, device_dni, device_ip, device_info) local credential = device:get_field(fields.CREDENTIAL) + if not credential then + log.error("update_connection : failed to find credential, dni = " .. tostring(device_dni)) + device:offline() + return + end conn_info:add_header(CREDENTIAL_KEY_HEADER, credential) diff --git a/drivers/SmartThings/jbl/src/jbl/api.lua b/drivers/SmartThings/jbl/src/jbl/api.lua index 95e25d14c3..7479289549 100644 --- a/drivers/SmartThings/jbl/src/jbl/api.lua +++ b/drivers/SmartThings/jbl/src/jbl/api.lua @@ -77,7 +77,7 @@ function jbl_api.new_device_manager(bridge_ip, bridge_info, socket_builder) end function jbl_api:add_header(key, value) - log.info("add_header : " .. key .. ", " .. value) + log.info("add_header : " .. tostring(key) .. ", " .. tostring(value)) self.headers[key] = value end diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/init.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/init.lua index c7057f82e4..fff466fea1 100644 --- a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/init.lua +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local ActivatedCarbonFilterMonitoringServerAttributes = require "ActivatedCarbonFilterMonitoring.server.attributes" local ActivatedCarbonFilterMonitoringTypes = require "ActivatedCarbonFilterMonitoring.types" diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/AttributeList.lua index ef3fb00a6a..9f34e51dbf 100644 --- a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/AttributeList.lua +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/AttributeList.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -123,4 +113,3 @@ end setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) return AttributeList - diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua index dd6fe1ecfb..71170e772f 100644 --- a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/server/attributes/ChangeIndication.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(ChangeIndication, {__call = ChangeIndication.new_value, __index = ChangeIndication.base_type}) return ChangeIndication - diff --git a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/init.lua b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/init.lua index 2ff8e6e89a..80e6e6b86a 100644 --- a/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/ActivatedCarbonFilterMonitoring/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/init.lua index 9a6be67486..382197bc60 100644 --- a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/init.lua +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local DishwasherAlarmServerAttributes = require "DishwasherAlarm.server.attributes" local DishwasherAlarmTypes = require "DishwasherAlarm.types" diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/init.lua index d5c4bba1fb..3ddec58dcc 100644 --- a/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/DishwasherAlarm/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/init.lua index 913f001fdb..0bb22e1884 100644 --- a/drivers/SmartThings/matter-appliance/src/DishwasherMode/init.lua +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local DishwasherModeServerAttributes = require "DishwasherMode.server.attributes" local DishwasherModeServerCommands = require "DishwasherMode.server.commands" diff --git a/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/init.lua index dd07b2f752..34d510160e 100644 --- a/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/DishwasherMode/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/init.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/init.lua index 21795104b7..b6df5d554c 100644 --- a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/init.lua +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local HepaFilterMonitoringServerAttributes = require "HepaFilterMonitoring.server.attributes" local HepaFilterMonitoringTypes = require "HepaFilterMonitoring.types" diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/AttributeList.lua index c4f817e428..6fdd2e5350 100644 --- a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/AttributeList.lua +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/AttributeList.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -123,4 +113,3 @@ end setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) return AttributeList - diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/ChangeIndication.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/ChangeIndication.lua index 06a80153e3..b205026e1b 100644 --- a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/ChangeIndication.lua +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/server/attributes/ChangeIndication.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- DO NOT EDIT: this code is automatically generated by ZCL Advanced Platform generator. @@ -113,4 +103,3 @@ end setmetatable(ChangeIndication, {__call = ChangeIndication.new_value, __index = ChangeIndication.base_type}) return ChangeIndication - diff --git a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/init.lua b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/init.lua index 77aca088ff..f4eea9ee26 100644 --- a/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/HepaFilterMonitoring/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/init.lua index 63bf4168e7..c6ff01850b 100644 --- a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/init.lua +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local LaundryWasherControlsServerAttributes = require "LaundryWasherControls.server.attributes" local LaundryWasherControlsTypes = require "LaundryWasherControls.types" diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/init.lua index e8a2f0ac53..4ba9f25726 100644 --- a/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherControls/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/init.lua index aa030e58cc..e64bde94a0 100644 --- a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/init.lua +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local LaundryWasherModeServerAttributes = require "LaundryWasherMode.server.attributes" local LaundryWasherModeServerCommands = require "LaundryWasherMode.server.commands" diff --git a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/init.lua index 057fe27dbe..d201d4ba36 100644 --- a/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/LaundryWasherMode/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/init.lua index 697aa3fd05..5c333dea1e 100644 --- a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/init.lua +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local MicrowaveOvenControlServerAttributes = require "MicrowaveOvenControl.server.attributes" local MicrowaveOvenControlServerCommands = require "MicrowaveOvenControl.server.commands" @@ -81,4 +85,4 @@ setmetatable(MicrowaveOvenControl.commands, command_helper_mt) setmetatable(MicrowaveOvenControl, {__index = cluster_base}) -return MicrowaveOvenControl \ No newline at end of file +return MicrowaveOvenControl diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/init.lua index 29c27b80ae..1d11195ee2 100644 --- a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenControl/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/init.lua index fdbbf4c443..dca8ff3ed5 100644 --- a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/init.lua +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local MicrowaveOvenModeServerAttributes = require "MicrowaveOvenMode.server.attributes" local MicrowaveOvenModeTypes = require "MicrowaveOvenMode.types" diff --git a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/init.lua index 412d69cabd..e67593141b 100644 --- a/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/MicrowaveOvenMode/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/init.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/init.lua index 5239cc1cfe..7be274fb8d 100644 --- a/drivers/SmartThings/matter-appliance/src/OperationalState/init.lua +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local OperationalStateServerAttributes = require "OperationalState.server.attributes" local OperationalStateServerCommands = require "OperationalState.server.commands" diff --git a/drivers/SmartThings/matter-appliance/src/OperationalState/types/init.lua b/drivers/SmartThings/matter-appliance/src/OperationalState/types/init.lua index 8c735ed06a..18bbca0824 100644 --- a/drivers/SmartThings/matter-appliance/src/OperationalState/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/OperationalState/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/init.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/init.lua index 76e1817dc1..4fc7c11376 100644 --- a/drivers/SmartThings/matter-appliance/src/OvenMode/init.lua +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local OvenModeServerAttributes = require "OvenMode.server.attributes" local OvenModeServerCommands = require "OvenMode.server.commands" diff --git a/drivers/SmartThings/matter-appliance/src/OvenMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/OvenMode/types/init.lua index 8bd0777339..b3565aaa2b 100644 --- a/drivers/SmartThings/matter-appliance/src/OvenMode/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/OvenMode/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/init.lua index a03452193c..1c9610b520 100644 --- a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/init.lua +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local RefrigeratorAlarmServerAttributes = require "RefrigeratorAlarm.server.attributes" local RefrigeratorAlarmTypes = require "RefrigeratorAlarm.types" diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/init.lua index 111371cb52..d8ebdc1ed3 100644 --- a/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAlarm/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/init.lua index f85f5e4e31..345b7b57b1 100644 --- a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/init.lua +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local RefrigeratorAndTemperatureControlledCabinetModeServerAttributes = require "RefrigeratorAndTemperatureControlledCabinetMode.server.attributes" local RefrigeratorAndTemperatureControlledCabinetModeServerCommands = require "RefrigeratorAndTemperatureControlledCabinetMode.server.commands" diff --git a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/init.lua b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/init.lua index d03e0d01d7..0079c8e25e 100644 --- a/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/RefrigeratorAndTemperatureControlledCabinetMode/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/init.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/init.lua index 2d453d8662..2ab1c94056 100644 --- a/drivers/SmartThings/matter-appliance/src/TemperatureControl/init.lua +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local TemperatureControlServerAttributes = require "TemperatureControl.server.attributes" local TemperatureControlServerCommands = require "TemperatureControl.server.commands" diff --git a/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/init.lua b/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/init.lua index feeafbc78b..53c77d6ff9 100644 --- a/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/init.lua +++ b/drivers/SmartThings/matter-appliance/src/TemperatureControl/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) diff --git a/drivers/SmartThings/matter-appliance/src/common-utils.lua b/drivers/SmartThings/matter-appliance/src/common-utils.lua index 1250d40912..d35ee2b6fc 100644 --- a/drivers/SmartThings/matter-appliance/src/common-utils.lua +++ b/drivers/SmartThings/matter-appliance/src/common-utils.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" diff --git a/drivers/SmartThings/matter-appliance/src/embedded-cluster-utils.lua b/drivers/SmartThings/matter-appliance/src/embedded-cluster-utils.lua index 52d9909dff..5a5a89af73 100644 --- a/drivers/SmartThings/matter-appliance/src/embedded-cluster-utils.lua +++ b/drivers/SmartThings/matter-appliance/src/embedded-cluster-utils.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.matter.clusters" local utils = require "st.utils" diff --git a/drivers/SmartThings/matter-appliance/src/init.lua b/drivers/SmartThings/matter-appliance/src/init.lua index d107a48fc5..8f91ebcd0c 100644 --- a/drivers/SmartThings/matter-appliance/src/init.lua +++ b/drivers/SmartThings/matter-appliance/src/init.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local MatterDriver = require "st.matter.driver" local capabilities = require "st.capabilities" @@ -307,15 +296,8 @@ local matter_driver_template = { capabilities.fanSpeedPercent, capabilities.windMode }, - sub_drivers = { - require("matter-cook-top"), - require("matter-dishwasher"), - require("matter-extractor-hood"), - require("matter-laundry"), - require("matter-microwave-oven"), - require("matter-oven"), - require("matter-refrigerator") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } local matter_driver = MatterDriver("matter-appliance", matter_driver_template) diff --git a/drivers/SmartThings/matter-appliance/src/lazy_load_subdriver.lua b/drivers/SmartThings/matter-appliance/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..a04740d267 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/lazy_load_subdriver.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + local MatterDriver = require "st.matter.driver" + local version = require "version" + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return MatterDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/matter-appliance/src/matter-cook-top/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-cook-top/can_handle.lua new file mode 100644 index 0000000000..d2899d279a --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-cook-top/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_cook_top_device(opts, driver, device, ...) + local common_utils = require "common-utils" + local COOK_TOP_DEVICE_TYPE_ID = 0x0078 + local OVEN_DEVICE_ID = 0x007B + + local cook_top_eps = common_utils.get_endpoints_for_dt(device, COOK_TOP_DEVICE_TYPE_ID) + local oven_eps = common_utils.get_endpoints_for_dt(device, OVEN_DEVICE_ID) + -- we want to skip lifecycle events in cases where the device is an oven with a composed cook-top device + if (#oven_eps > 0) and opts.dispatcher_class == "DeviceLifecycleDispatcher" then + return false + end + if #cook_top_eps > 0 then + return true, require("matter-cook-top") + end + return false +end + +return is_cook_top_device diff --git a/drivers/SmartThings/matter-appliance/src/matter-cook-top/init.lua b/drivers/SmartThings/matter-appliance/src/matter-cook-top/init.lua index 0a8f9f3182..d0ce48dc18 100644 --- a/drivers/SmartThings/matter-appliance/src/matter-cook-top/init.lua +++ b/drivers/SmartThings/matter-appliance/src/matter-cook-top/init.lua @@ -1,97 +1,73 @@ --- Copyright 2025 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. - -local clusters = require "st.matter.clusters" -local common_utils = require "common-utils" -local embedded_cluster_utils = require "embedded-cluster-utils" -local version = require "version" - -if version.api < 10 then - clusters.TemperatureControl = require "TemperatureControl" -end - -local COOK_SURFACE_DEVICE_TYPE_ID = 0x0077 -local COOK_TOP_DEVICE_TYPE_ID = 0x0078 -local OVEN_DEVICE_ID = 0x007B - -local function table_contains(tab, val) - for _, tab_val in ipairs(tab) do - if tab_val == val then - return true - end - end - return false -end - -local function device_added(driver, device) - local cook_surface_endpoints = common_utils.get_endpoints_for_dt(device, COOK_SURFACE_DEVICE_TYPE_ID) - local componentToEndpointMap = { - ["cookSurfaceOne"] = cook_surface_endpoints[1], - ["cookSurfaceTwo"] = cook_surface_endpoints[2] - } - device:set_field(common_utils.COMPONENT_TO_ENDPOINT_MAP, componentToEndpointMap, { persist = true }) -end - -local function do_configure(driver, device) - local cook_surface_endpoints = common_utils.get_endpoints_for_dt(device, COOK_SURFACE_DEVICE_TYPE_ID) - - local tl_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, - { feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_LEVEL }) - - local profile_name - if #cook_surface_endpoints > 0 then - profile_name = "cook-surface-one" - if table_contains(tl_eps, cook_surface_endpoints[1]) then - profile_name = profile_name .. "-tl" - end - - -- we only support up to two cook surfaces - if #cook_surface_endpoints > 1 then - profile_name = profile_name .. "-cook-surface-two" - if table_contains(tl_eps, cook_surface_endpoints[2]) then - profile_name = profile_name .. "-tl" - end - end - end - - if profile_name then - device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) - device:try_update_metadata({ profile = profile_name }) - end -end - -local function is_cook_top_device(opts, driver, device, ...) - local cook_top_eps = common_utils.get_endpoints_for_dt(device, COOK_TOP_DEVICE_TYPE_ID) - local oven_eps = common_utils.get_endpoints_for_dt(device, OVEN_DEVICE_ID) - -- we want to skip lifecycle events in cases where the device is an oven with a composed cook-top device - if (#oven_eps > 0) and opts.dispatcher_class == "DeviceLifecycleDispatcher" then - return false - end - if #cook_top_eps > 0 then - return true - end - return false -end - --- Matter Handlers -- -local matter_cook_top_handler = { - NAME = "matter-cook-top", - lifecycle_handlers = { - added = device_added, - doConfigure = do_configure - }, - can_handle = is_cook_top_device -} - -return matter_cook_top_handler +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.matter.clusters" +local common_utils = require "common-utils" +local embedded_cluster_utils = require "embedded-cluster-utils" +local version = require "version" + +if version.api < 10 then + clusters.TemperatureControl = require "TemperatureControl" +end + +local COOK_SURFACE_DEVICE_TYPE_ID = 0x0077 + +local function table_contains(tab, val) + for _, tab_val in ipairs(tab) do + if tab_val == val then + return true + end + end + return false +end + +local function device_added(driver, device) + local cook_surface_endpoints = common_utils.get_endpoints_for_dt(device, COOK_SURFACE_DEVICE_TYPE_ID) + local componentToEndpointMap = { + ["cookSurfaceOne"] = cook_surface_endpoints[1], + ["cookSurfaceTwo"] = cook_surface_endpoints[2] + } + device:set_field(common_utils.COMPONENT_TO_ENDPOINT_MAP, componentToEndpointMap, { persist = true }) +end + +local function do_configure(driver, device) + local cook_surface_endpoints = common_utils.get_endpoints_for_dt(device, COOK_SURFACE_DEVICE_TYPE_ID) + + local tl_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, + { feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_LEVEL }) + + local profile_name + if #cook_surface_endpoints > 0 then + profile_name = "cook-surface-one" + if table_contains(tl_eps, cook_surface_endpoints[1]) then + profile_name = profile_name .. "-tl" + end + + -- we only support up to two cook surfaces + if #cook_surface_endpoints > 1 then + profile_name = profile_name .. "-cook-surface-two" + if table_contains(tl_eps, cook_surface_endpoints[2]) then + profile_name = profile_name .. "-tl" + end + end + end + + if profile_name then + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({ profile = profile_name }) + end +end + + +-- Matter Handlers -- +local matter_cook_top_handler = { + NAME = "matter-cook-top", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + can_handle = require("matter-cook-top.can_handle"), +} + +return matter_cook_top_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-dishwasher/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-dishwasher/can_handle.lua new file mode 100644 index 0000000000..37226f3f3e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-dishwasher/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local function is_matter_dishwasher(opts, driver, device) + local DISHWASHER_DEVICE_TYPE_ID = 0x0075 + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == DISHWASHER_DEVICE_TYPE_ID then + return true, require("matter-dishwasher") + end + end + end + return false +end + +return is_matter_dishwasher diff --git a/drivers/SmartThings/matter-appliance/src/matter-dishwasher/init.lua b/drivers/SmartThings/matter-appliance/src/matter-dishwasher/init.lua index 96339fafc5..2e01afe7fb 100644 --- a/drivers/SmartThings/matter-appliance/src/matter-dishwasher/init.lua +++ b/drivers/SmartThings/matter-appliance/src/matter-dishwasher/init.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" @@ -18,8 +7,6 @@ local common_utils = require "common-utils" local embedded_cluster_utils = require "embedded-cluster-utils" local version = require "version" -local DISHWASHER_DEVICE_TYPE_ID = 0x0075 - if version.api < 10 then clusters.DishwasherAlarm = require "DishwasherAlarm" clusters.DishwasherMode = require "DishwasherMode" @@ -36,16 +23,6 @@ local OPERATIONAL_STATE_COMMAND_MAP = { local SUPPORTED_DISHWASHER_MODES = "__supported_dishwasher_modes" -local function is_matter_dishwasher(opts, driver, device) - for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == DISHWASHER_DEVICE_TYPE_ID then - return true - end - end - end - return false -end -- Lifecycle Handlers -- local function do_configure(driver, device) @@ -267,7 +244,7 @@ local matter_dishwasher_handler = { [capabilities.operationalState.commands.resume.NAME] = handle_operational_state_resume } }, - can_handle = is_matter_dishwasher + can_handle = require("matter-dishwasher.can_handle"), } return matter_dishwasher_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/can_handle.lua new file mode 100644 index 0000000000..d8ec2f9afd --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_extractor_hood(opts, driver, device) + local EXTRACTOR_HOOD_DEVICE_TYPE_ID = 0x007A + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == EXTRACTOR_HOOD_DEVICE_TYPE_ID then + return true, require("matter-extractor-hood") + end + end + end + return false +end + +return is_matter_extractor_hood diff --git a/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/init.lua b/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/init.lua index 57eef3ac94..f83681e726 100644 --- a/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/init.lua +++ b/drivers/SmartThings/matter-appliance/src/matter-extractor-hood/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" @@ -77,17 +67,6 @@ local function do_configure(driver, device) end -- Matter Handlers -- -local function is_matter_extractor_hood(opts, driver, device) - for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == EXTRACTOR_HOOD_DEVICE_TYPE_ID then - return true - end - end - end - return false -end - local function fan_mode_handler(driver, device, ib, response) if ib.data.value == clusters.FanControl.attributes.FanMode.OFF then device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode.off()) @@ -309,7 +288,7 @@ local matter_extractor_hood_handler = { [capabilities.windMode.commands.setWindMode.NAME] = set_wind_mode } }, - can_handle = is_matter_extractor_hood + can_handle = require("matter-extractor-hood.can_handle"), } return matter_extractor_hood_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-laundry/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-laundry/can_handle.lua new file mode 100644 index 0000000000..dd71f12e9e --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-laundry/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_laundry_device(opts, driver, device) + local LAUNDRY_WASHER_DEVICE_TYPE_ID = 0x0073 + local LAUNDRY_DRYER_DEVICE_TYPE_ID = 0x007C + local LAUNDRY_DEVICE_TYPE_ID= "__laundry_device_type_id" + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == LAUNDRY_WASHER_DEVICE_TYPE_ID or dt.device_type_id == LAUNDRY_DRYER_DEVICE_TYPE_ID then + device:set_field(LAUNDRY_DEVICE_TYPE_ID, dt.device_type_id, {persist = true}) + return dt.device_type_id, require("matter-laundry") + end + end + end + return false +end + +return is_matter_laundry_device diff --git a/drivers/SmartThings/matter-appliance/src/matter-laundry/init.lua b/drivers/SmartThings/matter-appliance/src/matter-laundry/init.lua index 8c49291da0..26b9efb606 100644 --- a/drivers/SmartThings/matter-appliance/src/matter-laundry/init.lua +++ b/drivers/SmartThings/matter-appliance/src/matter-laundry/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" @@ -47,23 +37,12 @@ local SUPPORTED_LAUNDRY_WASHER_MODES = "__supported_laundry_washer_modes" local SUPPORTED_LAUNDRY_WASHER_SPIN_SPEEDS = "__supported_laundry_spin_speeds" local SUPPORTED_LAUNDRY_WASHER_RINSES = "__supported_laundry_washer_rinses" -local function is_matter_laundry_device(opts, driver, device) - for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == LAUNDRY_WASHER_DEVICE_TYPE_ID or dt.device_type_id == LAUNDRY_DRYER_DEVICE_TYPE_ID then - device:set_field(LAUNDRY_DEVICE_TYPE_ID, dt.device_type_id, {persist = true}) - return dt.device_type_id - end - end - end - return false -end - -- Lifecycle Handlers -- local function do_configure(driver, device) local tn_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_NUMBER}) local tl_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureControl.ID, {feature_bitmap = clusters.TemperatureControl.types.Feature.TEMPERATURE_LEVEL}) - local device_type = is_matter_laundry_device({}, driver, device) + local is_matter_laundry_device = require("matter-laundry.can_handle") + local device_type, _ = is_matter_laundry_device({}, driver, device) local profile_name = "laundry" if (device_type == LAUNDRY_WASHER_DEVICE_TYPE_ID) then profile_name = profile_name.."-washer" @@ -316,7 +295,7 @@ local matter_laundry_handler = { [capabilities.operationalState.commands.resume.NAME] = handle_operational_state_resume } }, - can_handle = is_matter_laundry_device + can_handle = require("matter-laundry.can_handle"), } return matter_laundry_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/can_handle.lua new file mode 100644 index 0000000000..ebdda29863 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_mircowave_oven(opts, driver, device) + local MICROWAVE_OVEN_DEVICE_TYPE_ID = 0x0079 + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == MICROWAVE_OVEN_DEVICE_TYPE_ID then + return true, require("matter-microwave-oven") + end + end + end + return false +end + +return is_matter_mircowave_oven diff --git a/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/init.lua b/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/init.lua index 6ebd6a4c69..342723656a 100644 --- a/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/init.lua +++ b/drivers/SmartThings/matter-appliance/src/matter-microwave-oven/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" @@ -34,7 +24,6 @@ local OPERATIONAL_STATE_COMMAND_MAP = { [clusters.OperationalState.commands.Resume.ID] = "resume", } -local MICROWAVE_OVEN_DEVICE_TYPE_ID = 0x0079 local DEFAULT_COOKING_MODE = 0 local DEFAULT_COOKING_TIME = 30 local MICROWAVE_OVEN_SUPPORTED_MODES_KEY = "__microwave_oven_supported_modes__" @@ -45,16 +34,6 @@ local function device_init(driver, device) device:send(clusters.MicrowaveOvenControl.attributes.MaxCookTime:read(device, device.MATTER_DEFAULT_ENDPOINT)) end -local function is_matter_mircowave_oven(opts, driver, device) - for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == MICROWAVE_OVEN_DEVICE_TYPE_ID then - return true - end - end - end - return false -end local function get_last_set_cooking_parameters(device) local cookingTime = device:get_latest_state("main", capabilities.cookTime.ID, capabilities.cookTime.cookTime.NAME) or DEFAULT_COOKING_TIME @@ -271,7 +250,7 @@ local matter_microwave_oven = { capabilities.mode, capabilities.cookTime }, - can_handle = is_matter_mircowave_oven + can_handle = require("matter-microwave-oven.can_handle"), } return matter_microwave_oven diff --git a/drivers/SmartThings/matter-appliance/src/matter-oven/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-oven/can_handle.lua new file mode 100644 index 0000000000..a5bb61f9b8 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-oven/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_oven_device(opts, driver, device) + local common_utils = require "common-utils" + local OVEN_DEVICE_ID = 0x007B + local oven_eps = common_utils.get_endpoints_for_dt(device, OVEN_DEVICE_ID) + if #oven_eps > 0 then + return true, require("matter-oven") + end + return false +end + +return is_oven_device diff --git a/drivers/SmartThings/matter-appliance/src/matter-oven/init.lua b/drivers/SmartThings/matter-appliance/src/matter-oven/init.lua index 9847f24191..6c31504a6a 100644 --- a/drivers/SmartThings/matter-appliance/src/matter-oven/init.lua +++ b/drivers/SmartThings/matter-appliance/src/matter-oven/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" @@ -28,18 +18,10 @@ end local SUPPORTED_OVEN_MODES_MAP = "__supported_oven_modes_map_key_" -local OVEN_DEVICE_ID = 0x007B local COOK_SURFACE_DEVICE_TYPE_ID = 0x0077 local COOK_TOP_DEVICE_TYPE_ID = 0x0078 local TCC_DEVICE_TYPE_ID = 0x0071 -local function is_oven_device(opts, driver, device) - local oven_eps = common_utils.get_endpoints_for_dt(device, OVEN_DEVICE_ID) - if #oven_eps > 0 then - return true - end - return false -end -- Lifecycle Handlers -- local function device_added(driver, device) @@ -141,7 +123,7 @@ local matter_oven_handler = { [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = handle_temperature_setpoint } }, - can_handle = is_oven_device + can_handle = require("matter-oven.can_handle"), } return matter_oven_handler diff --git a/drivers/SmartThings/matter-appliance/src/matter-refrigerator/can_handle.lua b/drivers/SmartThings/matter-appliance/src/matter-refrigerator/can_handle.lua new file mode 100644 index 0000000000..376b32cc2d --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/matter-refrigerator/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_matter_refrigerator(opts, driver, device) + local REFRIGERATOR_DEVICE_TYPE_ID = 0x0070 + for _, ep in ipairs(device.endpoints) do + for _, dt in ipairs(ep.device_types) do + if dt.device_type_id == REFRIGERATOR_DEVICE_TYPE_ID then + return true, require("matter-refrigerator") + end + end + end + return false +end + +return is_matter_refrigerator diff --git a/drivers/SmartThings/matter-appliance/src/matter-refrigerator/init.lua b/drivers/SmartThings/matter-appliance/src/matter-refrigerator/init.lua index ec57c61505..c48412e849 100644 --- a/drivers/SmartThings/matter-appliance/src/matter-refrigerator/init.lua +++ b/drivers/SmartThings/matter-appliance/src/matter-refrigerator/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" @@ -18,7 +8,6 @@ local common_utils = require "common-utils" local embedded_cluster_utils = require "embedded-cluster-utils" local version = require "version" -local REFRIGERATOR_DEVICE_TYPE_ID = 0x0070 local TEMPERATURE_CONTROLLED_CABINET_DEVICE_TYPE_ID = 0x0071 if version.api < 10 then @@ -29,17 +18,6 @@ end local SUPPORTED_REFRIGERATOR_TCC_MODES_MAP = "__supported_refrigerator_tcc_modes_map" -local function is_matter_refrigerator(opts, driver, device) - for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - if dt.device_type_id == REFRIGERATOR_DEVICE_TYPE_ID then - return true - end - end - end - return false -end - -- Lifecycle Handlers -- local function device_added(driver, device) local cabinet_eps = {} @@ -182,7 +160,7 @@ local matter_refrigerator_handler = { [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = handle_temperature_setpoint } }, - can_handle = is_matter_refrigerator + can_handle = require("matter-refrigerator.can_handle"), } return matter_refrigerator_handler diff --git a/drivers/SmartThings/matter-appliance/src/sub_drivers.lua b/drivers/SmartThings/matter-appliance/src/sub_drivers.lua new file mode 100644 index 0000000000..e0ad824520 --- /dev/null +++ b/drivers/SmartThings/matter-appliance/src/sub_drivers.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("matter-cook-top"), + lazy_load_if_possible("matter-dishwasher"), + lazy_load_if_possible("matter-extractor-hood"), + lazy_load_if_possible("matter-laundry"), + lazy_load_if_possible("matter-microwave-oven"), + lazy_load_if_possible("matter-oven"), + lazy_load_if_possible("matter-refrigerator"), +} +return sub_drivers diff --git a/drivers/SmartThings/matter-appliance/src/test/test_cook_top.lua b/drivers/SmartThings/matter-appliance/src/test/test_cook_top.lua index 9aa45f7951..4c489ee38d 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_cook_top.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_cook_top.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -99,7 +89,10 @@ test.register_coroutine_test( local component_to_endpoint_map = mock_device:get_field("__component_to_endpoint_map") assert(component_to_endpoint_map["cookSurfaceOne"] == COOK_SURFACE_ONE_ENDPOINT, "Cook Surface One Endpoint must be 2") assert(component_to_endpoint_map["cookSurfaceTwo"] == COOK_SURFACE_TWO_ENDPOINT, "Cook Surface Two Endpoint must be 3") - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -122,6 +115,9 @@ test.register_message_test( clusters.OnOff.server.commands.Off(mock_device, COOK_TOP_ENDPOINT) } } + }, + { + min_api_version = 15 } ) @@ -159,6 +155,9 @@ test.register_message_test( clusters.TemperatureControl.server.commands.SetTemperature(mock_device, COOK_SURFACE_TWO_ENDPOINT, nil, 0) --0 is the index where Level1 is stored. } }, + }, + { + min_api_version = 15 } ) @@ -191,7 +190,10 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("cookSurfaceTwo", capabilities.temperatureMeasurement.temperature({ value = 20.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_dishwasher.lua b/drivers/SmartThings/matter-appliance/src/test/test_dishwasher.lua index c6923c3de4..93b6bbc3f3 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_dishwasher.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_dishwasher.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" test.set_rpc_version(6) @@ -111,6 +101,9 @@ test.register_message_test( clusters.OnOff.server.commands.Off(mock_device, APPLICATION_ENDPOINT) } } + }, + { + min_api_version = 15 } ) @@ -176,6 +169,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -241,6 +237,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -306,6 +305,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -382,6 +384,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -443,6 +448,9 @@ test.register_message_test( clusters.DishwasherMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, 1) --1 is the index where Super Dry is stored. } } + }, + { + min_api_version = 15 } ) @@ -480,6 +488,9 @@ test.register_message_test( clusters.TemperatureControl.server.commands.SetTemperature(mock_device, APPLICATION_ENDPOINT, nil, 0) --0 is the index where Level1 is stored. } }, + }, + { + min_api_version = 15 } ) @@ -536,6 +547,9 @@ test.register_message_test( clusters.TemperatureControl.commands.SetTemperature(mock_device, APPLICATION_ENDPOINT, 40 * 100, nil) } }, + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-appliance/src/test/test_extractor_hood.lua b/drivers/SmartThings/matter-appliance/src/test/test_extractor_hood.lua index 91339b78ca..f326ba50b8 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_extractor_hood.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_extractor_hood.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -184,6 +174,9 @@ test.register_message_test( clusters.FanControl.attributes.PercentSetting:write(mock_device, 1, 50) } } + }, + { + min_api_version = 15 } ) @@ -255,6 +248,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("auto")) } + }, + { + min_api_version = 15 } ) @@ -341,6 +337,9 @@ test.register_message_test( clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.types.FanModeEnum.AUTO) } } + }, + { + min_api_version = 15 } ) test.register_message_test( @@ -451,6 +450,9 @@ test.register_message_test( capabilities.fanMode.fanMode.high.NAME }, {visibility={displayed=false}})) } + }, + { + min_api_version = 15 } ) @@ -486,6 +488,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.windMode.windMode.naturalWind()) } + }, + { + min_api_version = 15 } ) @@ -524,6 +529,9 @@ test.register_message_test( clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) } } + }, + { + min_api_version = 15 } ) @@ -582,6 +590,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("hepaFilter", capabilities.filterStatus.filterStatus.replace()) }, + }, + { + min_api_version = 15 } ) @@ -640,6 +651,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.replace()) }, + }, + { + min_api_version = 15 } ) @@ -650,7 +664,10 @@ test.register_coroutine_test( mock_device_onoff:expect_metadata_update({ profile = "extractor-hood-wind-light" }) mock_device_onoff:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, - { test_init = test_init_onoff } + { + test_init = test_init_onoff, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -673,7 +690,10 @@ test.register_coroutine_test( clusters.OnOff.server.commands.Off(mock_device_onoff, 2) }) end, - { test_init = test_init_onoff } + { + test_init = test_init_onoff, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -694,7 +714,10 @@ test.register_coroutine_test( mock_device_onoff:generate_test_message("light", capabilities.switch.switch.off()) ) end, - { test_init = test_init_onoff } + { + test_init = test_init_onoff, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_laundry_dryer.lua b/drivers/SmartThings/matter-appliance/src/test/test_laundry_dryer.lua index 10e0de0f5e..d942817d57 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_laundry_dryer.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_laundry_dryer.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" test.set_rpc_version(6) @@ -110,6 +100,9 @@ test.register_message_test( clusters.OnOff.server.commands.Off(mock_device, APPLICATION_ENDPOINT) } } + }, + { + min_api_version = 15 } ) @@ -175,6 +168,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -240,6 +236,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -305,6 +304,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -381,6 +383,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -442,6 +447,9 @@ test.register_message_test( clusters.LaundryWasherMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, 1) --1 is the index where Super Dry is stored. } } + }, + { + min_api_version = 15 } ) @@ -479,6 +487,9 @@ test.register_message_test( clusters.TemperatureControl.server.commands.SetTemperature(mock_device, APPLICATION_ENDPOINT, nil, 0) --0 is the index where Level1 is stored. } }, + }, + { + min_api_version = 15 } ) @@ -535,6 +546,9 @@ test.register_message_test( clusters.TemperatureControl.commands.SetTemperature(mock_device, APPLICATION_ENDPOINT, 40 * 100, nil) } }, + }, + { + min_api_version = 15 } ) @@ -575,6 +589,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 50.0, unit = "C"})) } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-appliance/src/test/test_laundry_washer.lua b/drivers/SmartThings/matter-appliance/src/test/test_laundry_washer.lua index 0334bf61cb..288d76778e 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_laundry_washer.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_laundry_washer.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" test.set_rpc_version(6) @@ -143,6 +133,9 @@ test.register_message_test( clusters.TemperatureControl.commands.SetTemperature(mock_device_washer, APPLICATION_ENDPOINT, 25 * 100, nil) } }, + }, + { + min_api_version = 15 } ) @@ -183,6 +176,9 @@ test.register_message_test( direction = "send", message = mock_device_washer:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({value = 30.0, unit = "C"})) } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-appliance/src/test/test_matter_appliance_rpc_5.lua b/drivers/SmartThings/matter-appliance/src/test/test_matter_appliance_rpc_5.lua index 8f9a8c81d8..cc270cdddb 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_matter_appliance_rpc_5.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_matter_appliance_rpc_5.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" test.set_rpc_version(5) @@ -466,7 +456,10 @@ test.register_coroutine_test( { mock_device_dishwasher.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_dishwasher, dishwasher_ep, 40 * 100, nil) } ) end, - { test_init = test_init_dishwasher } + { + test_init = test_init_dishwasher, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -505,7 +498,10 @@ test.register_coroutine_test( { mock_device_dishwasher.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_dishwasher, dishwasher_ep, 50 * 100, nil) } ) end, - { test_init = test_init_dishwasher } + { + test_init = test_init_dishwasher, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -544,7 +540,10 @@ test.register_coroutine_test( { mock_device_washer.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_washer, washer_ep, 28 * 100, nil) } ) end, - { test_init = test_init_washer } + { + test_init = test_init_washer, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -583,7 +582,10 @@ test.register_coroutine_test( { mock_device_washer.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_washer, washer_ep, 50 * 100, nil) } ) end, - { test_init = test_init_washer } + { + test_init = test_init_washer, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -622,7 +624,10 @@ test.register_coroutine_test( { mock_device_dryer.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_dryer, dryer_ep, 40 * 100, nil) } ) end, - { test_init = test_init_dryer } + { + test_init = test_init_dryer, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -661,7 +666,10 @@ test.register_coroutine_test( { mock_device_dryer.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_dryer, dryer_ep, 40 * 100, nil) } ) end, - { test_init = test_init_dryer } + { + test_init = test_init_dryer, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -700,7 +708,10 @@ test.register_coroutine_test( { mock_device_oven.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_oven, oven_tcc_one_ep, 140 * 100, nil) } ) end, - { test_init = test_init_oven } + { + test_init = test_init_oven, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -739,7 +750,10 @@ test.register_coroutine_test( { mock_device_oven.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_oven, oven_tcc_one_ep, 140 * 100, nil) } ) end, - { test_init = test_init_oven } + { + test_init = test_init_oven, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -778,7 +792,10 @@ test.register_coroutine_test( { mock_device_refrigerator.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_refrigerator, refrigerator_ep, 4 * 100, nil) } ) end, - { test_init = test_init_refrigerator } + { + test_init = test_init_refrigerator, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -817,7 +834,10 @@ test.register_coroutine_test( { mock_device_refrigerator.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_refrigerator, refrigerator_ep, 10 * 100, nil) } ) end, - { test_init = test_init_refrigerator } + { + test_init = test_init_refrigerator, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -856,7 +876,10 @@ test.register_coroutine_test( { mock_device_refrigerator.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_refrigerator, freezer_ep, -15 * 100, nil) } ) end, - { test_init = test_init_refrigerator } + { + test_init = test_init_refrigerator, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -895,7 +918,10 @@ test.register_coroutine_test( { mock_device_refrigerator.id, clusters.TemperatureControl.commands.SetTemperature(mock_device_refrigerator, freezer_ep, -20 * 100, nil) } ) end, - { test_init = test_init_refrigerator } + { + test_init = test_init_refrigerator, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-appliance/src/test/test_microwave_oven.lua b/drivers/SmartThings/matter-appliance/src/test/test_microwave_oven.lua index 3c2e1fbc51..b6c5f3091d 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_microwave_oven.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_microwave_oven.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -182,6 +172,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -264,7 +257,8 @@ test.register_message_test( test_init = function() test_init() init_supported_microwave_oven_modes() - end + end, + min_api_version = 15 } ) @@ -342,6 +336,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -454,6 +451,9 @@ test.register_message_test( })) } }, -- on receiving NO ERROR we don't do anything. + }, + { + min_api_version = 15 } ) @@ -477,6 +477,9 @@ test.register_message_test( maximum = 900 },{visibility={displayed=false}})) }, + }, + { + min_api_version = 15 } ) @@ -567,6 +570,9 @@ test.register_message_test( 300) } }, + }, + { + min_api_version = 19 } ) diff --git a/drivers/SmartThings/matter-appliance/src/test/test_oven.lua b/drivers/SmartThings/matter-appliance/src/test/test_oven.lua index 58c38e078c..737f67de12 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_oven.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_oven.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" test.set_rpc_version(6) @@ -151,7 +141,10 @@ test.register_coroutine_test( "Cook Surface One Endpoint must be 5") assert(component_to_endpoint_map["cookSurfaceTwo"] == COOK_SURFACE_TWO_ENDPOINT, "Cook Surface Two Endpoint must be 6") - end + end, + { + min_api_version = 15 + } ) @@ -211,6 +204,9 @@ test.register_message_test( clusters.OvenMode.commands.ChangeToMode(mock_device, OVEN_TCC_ONE_ENDPOINT, 0) --Index where Grill is stored) } } + }, + { + min_api_version = 19 } ) @@ -230,6 +226,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("tccOne", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -286,6 +285,9 @@ test.register_message_test( clusters.TemperatureControl.commands.SetTemperature(mock_device, OVEN_TCC_ONE_ENDPOINT, 130 * 100, nil) } }, + }, + { + min_api_version = 15 } ) @@ -345,6 +347,9 @@ test.register_message_test( clusters.OvenMode.commands.ChangeToMode(mock_device, OVEN_TCC_TWO_ENDPOINT, 1) --Index where Pre Heat is stored } } + }, + { + min_api_version = 19 } ) @@ -364,6 +369,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("tccTwo", capabilities.temperatureMeasurement.temperature({ value = 50.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -401,6 +409,9 @@ test.register_message_test( clusters.TemperatureControl.server.commands.SetTemperature(mock_device, OVEN_TCC_TWO_ENDPOINT, nil, 0) --0 is the index where Level1 is stored. } }, + }, + { + min_api_version = 15 } ) @@ -424,6 +435,9 @@ test.register_message_test( clusters.OnOff.server.commands.Off(mock_device, COOK_TOP_ENDPOINT) } } + }, + { + min_api_version = 15 } ) @@ -459,6 +473,9 @@ test.register_message_test( clusters.TemperatureControl.server.commands.SetTemperature(mock_device, COOK_SURFACE_ONE_ENDPOINT, nil, 2) -- 2 is the index where Level 5 is stored. } }, + }, + { + min_api_version = 15 } ) @@ -494,6 +511,9 @@ test.register_message_test( clusters.TemperatureControl.server.commands.SetTemperature(mock_device, COOK_SURFACE_TWO_ENDPOINT, nil, 1) -- 1 is the index where Level 4 is stored. } }, + }, + { + min_api_version = 15 } ) @@ -513,6 +533,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("cookSurfaceOne", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -532,6 +555,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("cookSurfaceTwo", capabilities.temperatureMeasurement.temperature({ value = 20.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-appliance/src/test/test_refrigerator.lua b/drivers/SmartThings/matter-appliance/src/test/test_refrigerator.lua index 3dc19750f3..6fce3a45fb 100644 --- a/drivers/SmartThings/matter-appliance/src/test/test_refrigerator.lua +++ b/drivers/SmartThings/matter-appliance/src/test/test_refrigerator.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" test.set_rpc_version(6) @@ -155,6 +145,9 @@ test.register_message_test( clusters.RefrigeratorAndTemperatureControlledCabinetMode.server.commands.ChangeToMode(mock_device, refrigerator_ep, 1) --1 is the index where Super Dry is stored. } } + }, + { + min_api_version = 15 } ) @@ -211,6 +204,9 @@ test.register_message_test( clusters.TemperatureControl.commands.SetTemperature(mock_device, refrigerator_ep, 4 * 100, nil) } }, + }, + { + min_api_version = 15 } ) @@ -251,6 +247,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("refrigerator", capabilities.temperatureSetpoint.temperatureSetpoint({value = 7.0, unit = "C"})) } + }, + { + min_api_version = 15 } ) @@ -307,6 +306,9 @@ test.register_message_test( clusters.TemperatureControl.commands.SetTemperature(mock_device, freezer_ep, -15 * 100, nil) } }, + }, + { + min_api_version = 15 } ) @@ -347,6 +349,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("freezer", capabilities.temperatureSetpoint.temperatureSetpoint({value = -15.0, unit = "C"})) } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-button/src/test/skip_test_latching_switch.lua b/drivers/SmartThings/matter-button/src/test/skip_test_latching_switch.lua index 58475ae0cc..084dc9048a 100644 --- a/drivers/SmartThings/matter-button/src/test/skip_test_latching_switch.lua +++ b/drivers/SmartThings/matter-button/src/test/skip_test_latching_switch.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -81,7 +84,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( @@ -116,7 +122,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( @@ -135,7 +144,10 @@ test.register_coroutine_test( ) ) test.wait_for_events() - end + end, + { + min_api_version = 19 + } ) -- run the tests diff --git a/drivers/SmartThings/matter-button/src/test/test_matter_button.lua b/drivers/SmartThings/matter-button/src/test/test_matter_button.lua index c18daeab98..98b46650f0 100644 --- a/drivers/SmartThings/matter-button/src/test/test_matter_button.lua +++ b/drivers/SmartThings/matter-button/src/test/test_matter_button.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -66,6 +69,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 14 } ) @@ -101,6 +107,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) } +}, +{ + min_api_version = 14 } ) @@ -137,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) }, + }, + { + min_api_version = 14 } ) @@ -182,6 +194,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 14 } ) @@ -203,6 +218,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 14 } ) @@ -224,6 +242,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 14 } ) @@ -245,6 +266,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 14 } ) @@ -282,6 +306,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) }, +}, +{ + min_api_version = 14 } ) @@ -319,6 +346,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) }, +}, +{ + min_api_version = 14 } ) @@ -341,6 +371,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 14 } ) -- run the tests diff --git a/drivers/SmartThings/matter-button/src/test/test_matter_button_parent_child.lua b/drivers/SmartThings/matter-button/src/test/test_matter_button_parent_child.lua index a9d2629803..a8e1ecd69f 100644 --- a/drivers/SmartThings/matter-button/src/test/test_matter_button_parent_child.lua +++ b/drivers/SmartThings/matter-button/src/test/test_matter_button_parent_child.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -131,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 14 } ) @@ -162,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_children[3]:generate_test_message("main", button_attr.pushed({state_change = true})) }, + }, + { + min_api_version = 14 } ) @@ -202,6 +211,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 14 } ) @@ -223,6 +235,9 @@ test.register_message_test( message = mock_children[4]:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 14 } ) @@ -244,6 +259,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 14 } ) @@ -275,6 +293,9 @@ test.register_message_test( message = mock_children[4]:generate_test_message("main", button_attr.double({state_change = true})) }, +}, +{ + min_api_version = 14 } ) @@ -306,6 +327,9 @@ test.register_message_test( message = mock_children[4]:generate_test_message("main", button_attr.pushed_4x({state_change = true})) }, +}, +{ + min_api_version = 14 } ) @@ -328,6 +352,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 14 } ) -- run the tests diff --git a/drivers/SmartThings/matter-button/src/test/test_matter_multi_button.lua b/drivers/SmartThings/matter-button/src/test/test_matter_multi_button.lua index 2ecf083ba7..9158f8c6cd 100644 --- a/drivers/SmartThings/matter-button/src/test/test_matter_multi_button.lua +++ b/drivers/SmartThings/matter-button/src/test/test_matter_multi_button.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -131,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 15 } ) @@ -161,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 15 } ) @@ -183,7 +192,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.held({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -205,7 +217,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -226,7 +241,10 @@ test.register_coroutine_test( mock_device, 50, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -263,7 +281,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button4", button_attr.double({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -288,7 +309,10 @@ test.register_coroutine_test( mock_device, 30, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -313,7 +337,10 @@ test.register_coroutine_test( mock_device, 50, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -348,6 +375,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) } +}, +{ + min_api_version = 15 } ) @@ -384,6 +414,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) }, + }, + { + min_api_version = 15 } ) @@ -429,6 +462,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 15 } ) @@ -450,6 +486,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -471,6 +510,9 @@ test.register_message_test( message = mock_device:generate_test_message("button4", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -492,6 +534,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -529,6 +574,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) }, +}, +{ + min_api_version = 15 } ) @@ -566,6 +614,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) }, +}, +{ + min_api_version = 15 } ) @@ -588,6 +639,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 15 } ) @@ -630,6 +684,9 @@ test.register_message_test( } } -- no double event +}, +{ + min_api_version = 15 } ) @@ -685,6 +742,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button4", button_attr.pushed({state_change = true})) } + }, + { + min_api_version = 15 } ) -- run the tests diff --git a/drivers/SmartThings/matter-energy/src/init.lua b/drivers/SmartThings/matter-energy/src/init.lua index 51d361753a..c6e44008d6 100644 --- a/drivers/SmartThings/matter-energy/src/init.lua +++ b/drivers/SmartThings/matter-energy/src/init.lua @@ -750,6 +750,7 @@ matter_driver_template = { capabilities.battery, capabilities.chargingState }, + shared_device_thread_enabled = true, } local matter_driver = MatterDriver("matter-energy", matter_driver_template) diff --git a/drivers/SmartThings/matter-energy/src/test/test_battery_storage.lua b/drivers/SmartThings/matter-energy/src/test/test_battery_storage.lua index 5b5734acf5..4c899a2042 100644 --- a/drivers/SmartThings/matter-energy/src/test/test_battery_storage.lua +++ b/drivers/SmartThings/matter-energy/src/test/test_battery_storage.lua @@ -102,7 +102,10 @@ test.register_coroutine_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( "Battery charge state must reported properly", @@ -157,7 +160,10 @@ test.register_coroutine_test( mock_device:generate_test_message( "main", capabilities.chargingState.chargingState.error()) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -171,7 +177,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 30.0, unit = "W" }))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -185,7 +194,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 30.0, unit = "W" }))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -288,7 +300,8 @@ test.register_coroutine_test( { test_init = function() test_init() - end + end, + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-energy/src/test/test_evse.lua b/drivers/SmartThings/matter-energy/src/test/test_evse.lua index 6add71bde9..d1b9c92324 100644 --- a/drivers/SmartThings/matter-energy/src/test/test_evse.lua +++ b/drivers/SmartThings/matter-energy/src/test/test_evse.lua @@ -118,7 +118,10 @@ test.register_coroutine_test( assert(component_to_endpoint_map["electricalSensor"] == ELECTRICAL_SENSOR_EP, "Electrical Sensor Endpoint must be 2") assert(component_to_endpoint_map["deviceEnergyManagement"] == DEVICE_ENERGY_MANAGEMENT_DEVICE_EP, "Device Energy Management Endpoint must be 3") - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -145,6 +148,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.evseChargingSession.chargingState.charging({state_change = true})) } + }, + { + min_api_version = 15 } ) @@ -193,6 +199,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.evseChargingSession.chargingState.charging({state_change = true})) }, + }, + { + min_api_version = 15 } ) @@ -214,6 +223,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.evseState.faultState.groundFault()) } + }, + { + min_api_version = 15 } ) @@ -234,6 +246,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.evseChargingSession.targetEndTime("2024-08-23T07:47:22Z")) } + }, + { + min_api_version = 15 } ) @@ -254,6 +269,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.evseChargingSession.minCurrent(0)) } + }, + { + min_api_version = 15 } ) @@ -274,6 +292,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.evseChargingSession.maxCurrent(10000)) } + }, + { + min_api_version = 15 } ) @@ -294,6 +315,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.evseChargingSession.sessionTime(9000)) } + }, + { + min_api_version = 15 } ) @@ -314,6 +338,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.evseChargingSession.energyDelivered(900000)) } + }, + { + min_api_version = 15 } ) @@ -335,6 +362,9 @@ test.register_message_test( message = mock_device:generate_test_message("electricalSensor", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 15 } ) @@ -406,6 +436,9 @@ test.register_message_test( clusters.EnergyEvseMode.commands.ChangeToMode(mock_device, EVSE_EP, 0) --Index is Auto-Scheduled } } + }, + { + min_api_version = 15 } ) @@ -506,6 +539,9 @@ test.register_message_test( clusters.DeviceEnergyManagementMode.commands.ChangeToMode(mock_device, DEVICE_ENERGY_MANAGEMENT_DEVICE_EP, 0) --Index is Grid Energy Management } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-energy/src/test/test_evse_energy_meas.lua b/drivers/SmartThings/matter-energy/src/test/test_evse_energy_meas.lua index 610c443625..bf56269261 100644 --- a/drivers/SmartThings/matter-energy/src/test/test_evse_energy_meas.lua +++ b/drivers/SmartThings/matter-energy/src/test/test_evse_energy_meas.lua @@ -116,7 +116,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update({ profile = "evse-energy-meas" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -148,7 +151,8 @@ test.register_coroutine_test( { test_init = function() test_init() - end + end, + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-energy/src/test/test_solar_power.lua b/drivers/SmartThings/matter-energy/src/test/test_solar_power.lua index c7446af44b..f69f034a86 100644 --- a/drivers/SmartThings/matter-energy/src/test/test_solar_power.lua +++ b/drivers/SmartThings/matter-energy/src/test/test_solar_power.lua @@ -122,7 +122,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 35.0, unit = "W" }))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -168,7 +171,8 @@ test.register_coroutine_test( { test_init = function() test_init() - end + end, + min_api_version = 15 } ) @@ -191,7 +195,10 @@ test.register_coroutine_test( .CumulativeEnergyImported:build_test_report_data(mock_device, SOLAR_POWER_EP_ONE, clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct({ energy = 100000, start_timestamp = 0, end_timestamp = 0, start_systime = 0, end_systime = 0, apparent_energy = 0, reactive_energy = 0 })) }) --100Wh - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua b/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua index 0467e19de3..4c894b7bdb 100644 --- a/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua +++ b/drivers/SmartThings/matter-hrap/src/test/test_thread_border_router_network.lua @@ -106,7 +106,10 @@ test.register_coroutine_test( mock_device, 1, 6 ) }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -138,6 +141,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.threadInterfaceState("disabled")) } + }, + { + min_api_version = 14 } ) @@ -183,6 +189,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.threadBorderRouter.borderRouterName({ value = "john foo no suffix"})) }, + }, + { + min_api_version = 14 } ) @@ -202,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.wifiInformation.ssid({ value = "test name for ssid!" })) } + }, + { + min_api_version = 14 } ) @@ -216,6 +228,9 @@ test.register_message_test( clusters.WifiNetworkMangement.attributes.Ssid:build_test_report_data(mock_device, 1, string.char(data_types.Null.ID)) } } + }, + { + min_api_version = 14 } ) @@ -230,6 +245,9 @@ test.register_message_test( clusters.WifiNetworkMangement.attributes.Ssid:build_test_report_data(mock_device, 1, string.char(0xC0)) -- 0xC0 never appears in utf8 } } + }, + { + min_api_version = 14 } ) @@ -336,7 +354,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.threadNetwork.panId({ value = 55672 })) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/fingerprints.yml b/drivers/SmartThings/matter-lock/fingerprints.yml index 6c7a223caf..b81ae4c48f 100755 --- a/drivers/SmartThings/matter-lock/fingerprints.yml +++ b/drivers/SmartThings/matter-lock/fingerprints.yml @@ -15,6 +15,41 @@ matterManufacturer: vendorId: 0x115F productId: 0x2807 deviceProfileName: lock-battery + - id: "4447/10346" + deviceLabel: Aqara Smart Lock U200 US + vendorId: 0x115F + productId: 0x286A + deviceProfileName: lock-user-pin + - id: "4447/10244" + deviceLabel: Aqara Smart Lock U400 + vendorId: 0x115F + productId: 0x2804 + deviceProfileName: lock + - id: "4447/10254" + deviceLabel: Aqara Smart Lock U500 + vendorId: 0x115F + productId: 0x280E + deviceProfileName: lock-modular + - id: "4447/10255" + deviceLabel: Aqara Smart Lock U500 Rim + vendorId: 0x115F + productId: 0x280F + deviceProfileName: lock-modular + - id: "4447/10256" + deviceLabel: Aqara Smart Lock U500 Glass Door + vendorId: 0x115F + productId: 0x2810 + deviceProfileName: lock-modular + - id: "4447/10314" + deviceLabel: Aqara Smart Lock U600 + vendorId: 0x115F + productId: 0x284A + deviceProfileName: lock-modular + - id: "4447/10253" + deviceLabel: Aqara Smart Lock J200 + vendorId: 0x115F + productId: 0x280D + deviceProfileName: lock-modular #Eufy - id: "5427/1" deviceLabel: eufy Smart Lock E31 @@ -56,59 +91,74 @@ matterManufacturer: vendorId: 0x1533 productId: 0x0016 deviceProfileName: lock-user-pin-battery + - id: 5427/20 + deviceLabel: eufy FamiLock E40 + vendorId: 0x1533 + productId: 0x0014 + deviceProfileName: lock-user-pin-battery #Kwikset - id: "5153/66" deviceLabel: Kwikset Halo Select Plus vendorId: 0x1421 productId: 0x0042 deviceProfileName: lock-user-pin-battery + - id: "5153/65" + deviceLabel: Halo Select Plus + vendorId: 0x1421 + productId: 0x0041 + deviceProfileName: lock-user-pin-battery + - id: "5153/129" + deviceLabel: Kwikset Aura Reach + vendorId: 0x1421 + productId: 0x0081 + deviceProfileName: lock-user-pin-battery #Level - id: "4767/1" deviceLabel: Level Lock Plus (Matter) vendorId: 0x129F productId: 0x0001 - deviceProfileName: lock-nocodes-notamper-batteryLevel + deviceProfileName: lock - id: "4767/3" deviceLabel: Level Bolt (Matter) vendorId: 0x129F productId: 0x0003 - deviceProfileName: lock-nocodes-notamper-batteryLevel + deviceProfileName: lock - id: "4767/4" deviceLabel: Level Lock Pro vendorId: 0x129F productId: 0x0004 - deviceProfileName: lock-nocodes-notamper-batteryLevel + deviceProfileName: lock - id: "4767/5" deviceLabel: Level Lock (Matter) vendorId: 0x129F productId: 0x0005 - deviceProfileName: lock-nocodes-notamper-batteryLevel + deviceProfileName: lock #Nuki - id: "4957/161" deviceLabel: Nuki Smart Lock Ultra vendorId: 0x135D productId: 0x00A1 - deviceProfileName: lock-nocodes-notamper + deviceProfileName: lock - id: "Nuki Smart Lock" deviceLabel: Nuki Smart Lock vendorId: 0x135d productId: 0xb0 - deviceProfileName: lock-nocodes-notamper + deviceProfileName: lock - id: "Nuki Smart Lock Pro" deviceLabel: Nuki Smart Lock Pro vendorId: 0x135d productId: 0xb1 - deviceProfileName: lock-nocodes-notamper + deviceProfileName: lock - id: "4957/178" deviceLabel: Nuki Smart Lock Pro vendorId: 0x135D productId: 0x00B2 - deviceProfileName: lock-nocodes-notamper + deviceProfileName: lock - id: "4957/193" deviceLabel: Nuki Smart Lock Go vendorId: 0x135D productId: 0x00C1 - deviceProfileName: lock-nocodes-notamper + deviceProfileName: lock #U-tec - id: "5247/1" deviceLabel: ULTRALOQ Bolt Fingerprint Smart Matter Deadbolt @@ -120,15 +170,53 @@ matterManufacturer: vendorId: 0x147F productId: 0x0008 deviceProfileName: lock-user-pin-battery + - id: "5247/7" + deviceLabel: ULTRALOQ Bolt Pro Smart Matter Door Lock + vendorId: 0x147F + productId: 0x0007 + deviceProfileName: lock-modular + - id: "5247/16" + deviceLabel: ULTRALOQ Latch 5 Pro Smart Matter Door Lock + vendorId: 0x147F + productId: 0x0010 + deviceProfileName: lock-modular #Yale - id: "4125/33040" deviceLabel: Yale Lock with Matter vendorId: 0x101D productId: 0x8110 deviceProfileName: lock-user-pin-schedule-battery + #Dreame + - id: "5420/38144" + deviceLabel: Dreame NAVO Smart Lock A10 + vendorId: 0x152C + productId: 0x9500 + deviceProfileName: lock + - id: "5420/38145" + deviceLabel: Dreame NAVO Smart Lock E10 + 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 deviceTypes: - id: 0x000A # Door Lock - deviceProfileName: base-lock + deviceProfileName: lock diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/init.lua b/drivers/SmartThings/matter-lock/src/DoorLock/init.lua index b9675ebf77..20cf12d0ea 100644 --- a/drivers/SmartThings/matter-lock/src/DoorLock/init.lua +++ b/drivers/SmartThings/matter-lock/src/DoorLock/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cluster_base = require "st.matter.cluster_base" local DoorLockServerAttributes = require "DoorLock.server.attributes" local DoorLockServerCommands = require "DoorLock.server.commands" @@ -255,4 +259,4 @@ setmetatable(DoorLock.events, event_helper_mt) setmetatable(DoorLock, {__index = cluster_base}) -return DoorLock \ No newline at end of file +return DoorLock diff --git a/drivers/SmartThings/matter-lock/src/DoorLock/types/init.lua b/drivers/SmartThings/matter-lock/src/DoorLock/types/init.lua index 461d914f84..f4b2939d61 100644 --- a/drivers/SmartThings/matter-lock/src/DoorLock/types/init.lua +++ b/drivers/SmartThings/matter-lock/src/DoorLock/types/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local types_mt = {} types_mt.__types_cache = {} types_mt.__index = function(self, key) @@ -11,4 +14,4 @@ local DoorLockTypes = {} setmetatable(DoorLockTypes, types_mt) -return DoorLockTypes \ No newline at end of file +return DoorLockTypes diff --git a/drivers/SmartThings/matter-lock/src/init.lua b/drivers/SmartThings/matter-lock/src/init.lua index 6133b74e45..72bfaae425 100755 --- a/drivers/SmartThings/matter-lock/src/init.lua +++ b/drivers/SmartThings/matter-lock/src/init.lua @@ -1,43 +1,490 @@ --- Copyright 2022 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local MatterDriver = require "st.matter.driver" +local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" +local utils = require "st.utils" +local lock_utils = require "lock_utils" + +local version = require "version" +if version.api < 10 then + clusters.DoorLock = require "DoorLock" +end local DoorLock = clusters.DoorLock local PowerSource = clusters.PowerSource -local capabilities = require "st.capabilities" -local im = require "st.matter.interaction_model" -local lock_utils = require "lock_utils" +local INITIAL_CREDENTIAL_INDEX = 1 +local ALL_INDEX = 0xFFFE +-- maximum as defined by the Matter specification +local MAX_USER_NAME_LENGTH = 10 -local PROFILE_BASE_NAME = "__profile_base_name" +local RESPONSE_STATUS_MAP = { + [DoorLock.types.DlStatus.SUCCESS] = "success", + [DoorLock.types.DlStatus.FAILURE] = "failure", + [DoorLock.types.DlStatus.DUPLICATE] = "duplicate", + [DoorLock.types.DlStatus.OCCUPIED] = "occupied", + [DoorLock.types.DlStatus.INVALID_FIELD] = "invalidCommand", + [DoorLock.types.DlStatus.RESOURCE_EXHAUSTED] = "resourceExhausted", + [DoorLock.types.DlStatus.NOT_FOUND] = "failure" +} + +local WEEK_DAY_MAP = { + ["Sunday"] = 1, + ["Monday"] = 2, + ["Tuesday"] = 4, + ["Wednesday"] = 8, + ["Thursday"] = 16, + ["Friday"] = 32, + ["Saturday"] = 64, +} + +local ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP = { + ["evictableEndpointKey"] = DoorLock.types.CredentialTypeEnum.ALIRO_EVICTABLE_ENDPOINT_KEY, + ["nonEvictableEndpointKey"] = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY +} -local INITIAL_COTA_INDEX = 1 +local battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" +} --- add this definition for locks to work on older lua libs -local UNLATCHED_STATE = 0x3 +local profiling_data = { + BATTERY_SUPPORT = "__BATTERY_SUPPORT", +} +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} local subscribed_attributes = { - [capabilities.lock.ID] = {DoorLock.attributes.LockState}, - [capabilities.battery.ID] = {PowerSource.attributes.BatPercentRemaining}, - [capabilities.batteryLevel.ID] = {PowerSource.attributes.BatChargeLevel}, + [capabilities.lock.ID] = { + DoorLock.attributes.LockState + }, + [capabilities.remoteControlStatus.ID] = { + DoorLock.attributes.OperatingMode + }, + [capabilities.lockUsers.ID] = { + DoorLock.attributes.NumberOfTotalUsersSupported + }, + [capabilities.lockCredentials.ID] = { + DoorLock.attributes.NumberOfPINUsersSupported, + DoorLock.attributes.MaxPINCodeLength, + DoorLock.attributes.MinPINCodeLength, + DoorLock.attributes.RequirePINforRemoteOperation + }, + [capabilities.lockSchedules.ID] = { + DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser, + DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser + }, + [capabilities.lockAliro.ID] = { + DoorLock.attributes.AliroReaderVerificationKey, + DoorLock.attributes.AliroReaderGroupIdentifier, + DoorLock.attributes.AliroReaderGroupSubIdentifier, + DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions, + DoorLock.attributes.AliroGroupResolvingKey, + DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions, + DoorLock.attributes.AliroBLEAdvertisingVersion, + DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported, + DoorLock.attributes.NumberOfAliroEndpointKeysSupported, + }, + [capabilities.battery.ID] = { + PowerSource.attributes.BatPercentRemaining + }, + [capabilities.batteryLevel.ID] = { + PowerSource.attributes.BatChargeLevel + } +} + +local subscribed_events = { + [capabilities.lock.ID] = { + DoorLock.events.LockOperation + }, + [capabilities.lockAlarm.ID] = { + DoorLock.events.DoorLockAlarm + }, + [capabilities.lockUsers.ID] = { + DoorLock.events.LockUserChange + } } ---- If a device needs a cota credential this function attempts to set the credential ---- at the index provided. The set_credential_response_handler handles all failures ---- and retries with the appropriate index when necessary. +local function find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = device:get_endpoints(cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + return v + end + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return res +end + +local function component_to_endpoint(device, component_name) + return find_default_endpoint(device, clusters.DoorLock.ID) +end + +local function device_init(driver, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) == 0 then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, {persist = true}) + elseif device:get_field(profiling_data.BATTERY_SUPPORT) == nil then + device:add_subscribed_attribute(clusters.PowerSource.attributes.AttributeList) + end + for cap_id, attributes in pairs(subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + device:add_subscribed_attribute(DoorLockFeatureMapAttr) + for cap_id, events in pairs(subscribed_events) do + if device:supports_capability_by_id(cap_id) then + for _, e in ipairs(events) do + device:add_subscribed_event(e) + end + end + end + device:subscribe() + end + +local function device_added(driver, device) + device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true})) +end + +local function set_reader_config(device) + local reader_config_updated = device:get_field(lock_utils.ALIRO_READER_CONFIG_UPDATED) or nil + if reader_config_updated == "TRUE" or reader_config_updated == "IN_PROGRESS" then return end + + local cmdName = "setReaderConfig" + local groupId = lock_utils.create_group_id_resolving_key() + local groupResolvingKey = nil + local aliro_ble_uwb_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.ALIROBLEUWB}) + if #aliro_ble_uwb_eps > 0 then + groupResolvingKey = lock_utils.create_group_id_resolving_key() + end + local privKey, pubKey = lock_utils.generate_keypair(device) + if not privKey or not pubKey then + local command_result_info = { + commandName = cmdName, + statusCode = "failure" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.VERIFICATION_KEY, pubKey, {persist = true}) + device:set_field(lock_utils.GROUP_ID, groupId, {persist = true}) + device:set_field(lock_utils.GROUP_RESOLVING_KEY, groupResolvingKey, {persist = true}) + + -- Send command + local ep = find_default_endpoint(device, clusters.DoorLock.ID) + device:send( + DoorLock.server.commands.SetAliroReaderConfig( + device, ep, + lock_utils.hex_string_to_octet_string(privKey), + lock_utils.hex_string_to_octet_string(pubKey), + lock_utils.hex_string_to_octet_string(groupId), + lock_utils.hex_string_to_octet_string(groupResolvingKey) + ) + ) + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "IN_PROGRESS", {persist = true}) +end + +local function match_profile_modular(driver, device) + local enabled_optional_component_capability_pairs = {} + local main_component_capabilities = {} + local modular_profile_name = "lock-modular" + for _, device_ep in pairs(device.endpoints) do + for _, ep_cluster in pairs(device_ep.clusters) do + if ep_cluster.cluster_id == DoorLock.ID then + local clus_has_feature = function(feature_bitmap) + return DoorLock.are_features_supported( + feature_bitmap, + lock_utils.get_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, device_ep.endpoint_id) or + ep_cluster.feature_map + ) + end + if clus_has_feature(DoorLock.types.Feature.USER) then + table.insert(main_component_capabilities, capabilities.lockUsers.ID) + end + if clus_has_feature(DoorLock.types.Feature.PIN_CREDENTIAL) then + table.insert(main_component_capabilities, capabilities.lockCredentials.ID) + end + if clus_has_feature(DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES) or + clus_has_feature(DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES) then + table.insert(main_component_capabilities, capabilities.lockSchedules.ID) + end + if clus_has_feature(DoorLock.types.Feature.UNBOLT) then + device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + modular_profile_name = "lock-modular-embedded-unlatch" -- use the embedded config specified in this profile for devices supporting "unlatch" + else + device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + end + if clus_has_feature(DoorLock.types.Feature.ALIRO_PROVISIONING) then + table.insert(main_component_capabilities, capabilities.lockAliro.ID) + end + break + end + end + end + + local supported_battery_type = device:get_field(profiling_data.BATTERY_SUPPORT) + if supported_battery_type == battery_support.BATTERY_LEVEL then + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + elseif supported_battery_type == battery_support.BATTERY_PERCENTAGE then + table.insert(main_component_capabilities, capabilities.battery.ID) + end + + table.insert(enabled_optional_component_capability_pairs, {"main", main_component_capabilities}) + if modular_profile_name == "lock-modular-embedded-unlatch" -- the embedded config that may be needed is not checked by an optional capability comparison + or device:supports_capability(capabilities.lockCodes) -- always re-profile a device using the legacy code storage methods + or lock_utils.optional_capabilities_list_changed(enabled_optional_component_capability_pairs, device.profile.components) then + device:try_update_metadata({profile = modular_profile_name, optional_component_capabilities = enabled_optional_component_capability_pairs}) + end +end + +local function match_profile_switch(driver, device) + local user_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.USER}) + local pin_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.PIN_CREDENTIAL}) + local week_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES}) + local year_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES}) + local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) + + local profile_name = "lock" + if #user_eps > 0 then + profile_name = profile_name .. "-user" + if #pin_eps > 0 then + profile_name = profile_name .. "-pin" + end + if #week_schedule_eps + #year_schedule_eps > 0 then + profile_name = profile_name .. "-schedule" + end + end + if #unbolt_eps > 0 then + profile_name = profile_name .. "-unlatch" + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + else + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + end + + local supported_battery_type = device:get_field(profiling_data.BATTERY_SUPPORT) + if supported_battery_type == battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + elseif supported_battery_type == battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + end + + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) +end + +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +local function deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return deep_equals(mt_a, mt_b, opts, seen) +end + +local function migrate_lock_codes(device) + local lockCodes = device:get_field(lock_utils.LOCK_CODES_FOR_MIGRATION) or {} + local user_table = {} + local credential_table = {} + for _, entry in ipairs(lockCodes) do + local index = tonumber(entry[1]) + table.insert(user_table, {userIndex = index, userType = "guest", userName = entry[2]}) + table.insert(credential_table, {userIndex = index, credentialIndex = index, credentialType = "pin"}) + end + if #user_table > 0 or #credential_table > 0 then + device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) + device:emit_event(capabilities.lockCredentials.credentials(credential_table, {visibility = {displayed = false}})) + end +end + +local function info_changed(driver, device, event, args) + if deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then + return + end + for cap_id, attributes in pairs(subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + for cap_id, events in pairs(subscribed_events) do + if device:supports_capability_by_id(cap_id) then + for _, e in ipairs(events) do + device:add_subscribed_event(e) + end + end + end + device:subscribe() + if device:supports_capability_by_id(capabilities.lockAliro.ID) then + set_reader_config(device) + end + if device:get_latest_state("main", capabilities.lockAlarm.ID, capabilities.lockAlarm.supportedAlarmValues.NAME) == nil then + device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true})) + device:emit_event(capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) -- lockJammed is mandatory + end + if device:get_field(lock_utils.LOCK_CODES_COPY_REQUIRED) == true then + migrate_lock_codes(device) + device:set_field(lock_utils.LOCK_CODES_COPY_REQUIRED, false, {persist = true}) + end +end + +local function profiling_data_still_required(device) + for _, field in pairs(profiling_data) do + if device:get_field(field) == nil then + return true -- data still required if a field is nil + end + end + return false +end + +local function match_profile(driver, device, ignore_static_profiling) + if profiling_data_still_required(device) then return end + if version.api >= 15 and version.rpc >= 9 then + match_profile_modular(driver, device) + elseif ignore_static_profiling ~= true then + match_profile_switch(driver, device) + end +end + +local function do_configure(driver, device) + match_profile(driver, device, false) + device.thread:call_with_delay(5, function() + device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true})) + device:emit_event(capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) -- lockJammed is mandatory + end) +end + +local function driver_switched(driver, device) + match_profile(driver, device, false) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +-- Matter Handler +local function lock_state_handler(driver, device, ib, response) + local LockState = DoorLock.attributes.LockState + local attr = capabilities.lock.lock + local LOCK_STATE = { + [LockState.NOT_FULLY_LOCKED] = attr.not_fully_locked(), + [LockState.LOCKED] = attr.locked(), + [LockState.UNLOCKED] = attr.unlocked(), + [LockState.UNLATCHED] = attr.unlatched() + } + + -- The lock state is usually updated in lock_state_handler and lock_op_event_handler, respectively. + -- In this case, two events occur. To prevent this, when both functions are called, + -- it send the event after 1 second so that no event occurs in the lock_state_handler. + device.thread:call_with_delay(1, function () + if ib.data.value ~= nil and LOCK_STATE[ib.data.value] ~= nil then + device:emit_event(LOCK_STATE[ib.data.value]) + else + device.log.warn(string.format("Received unknown Lock State: %s", ib.data.value)) + end + end) +end + +local function operating_modes_handler(driver, device, ib, response) + local status = capabilities.remoteControlStatus.remoteControlEnabled + local op_type = DoorLock.types.OperatingModeEnum + local opMode_map = { + [op_type.NORMAL] = true, + [op_type.VACATION] = true, + [op_type.PRIVACY] = false, + [op_type.NO_REMOTE_LOCK_UNLOCK] = false, + [op_type.PASSAGE] = false, + } + local result = opMode_map[ib.data.value] + local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) + if result == true then + device:emit_event(status("true", {visibility = {displayed = true}})) + if #unbolt_eps > 0 then + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + else + device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + end + elseif result == false then + device:emit_event(status("false", {visibility = {displayed = true}})) + device:emit_event(capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) + end +end + +local function total_users_supported_handler(driver, device, ib, response) + device:emit_event(capabilities.lockUsers.totalUsersSupported(ib.data.value, {visibility = {displayed = false}})) +end + +local function pin_users_supported_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCredentials.pinUsersSupported(ib.data.value, {visibility = {displayed = false}})) +end + +local function min_pin_code_len_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(ib.data.value, {visibility = {displayed = false}})) +end + +local function max_pin_code_len_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(ib.data.value, {visibility = {displayed = false}})) +end + local function set_cota_credential(device, credential_index) local eps = device:get_endpoints(DoorLock.ID) local cota_cred = device:get_field(lock_utils.COTA_CRED) @@ -50,7 +497,8 @@ local function set_cota_credential(device, credential_index) return end - if device:get_field(lock_utils.SET_CREDENTIAL) ~= nil then + -- Check Busy State + if lock_utils.is_busy_state_set(device) then device.log.debug("delaying setting COTA credential since a credential is currently being set") device.thread:call_with_delay(2, function(t) set_cota_credential(device, credential_index) @@ -58,44 +506,161 @@ local function set_cota_credential(device, credential_index) return end + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, "addCota") + device:set_field(lock_utils.CRED_INDEX, credential_index) device:set_field(lock_utils.COTA_CRED_INDEX, credential_index, {persist = true}) - local credential = {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = credential_index} - -- Set the credential to a code - device:set_field(lock_utils.SET_CREDENTIAL, credential_index) + device:set_field(lock_utils.USER_TYPE, "remote") + + -- Send command device.log.info(string.format("Attempting to set COTA credential at index %s", credential_index)) + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = credential_index + } device:send(DoorLock.server.commands.SetCredential( device, #eps > 0 and eps[1] or 1, - DoorLock.types.DlDataOperationType.ADD, + DoorLock.types.DataOperationTypeEnum.ADD, credential, device:get_field(lock_utils.COTA_CRED), nil, -- nil user_index creates a new user - DoorLock.types.DlUserStatus.OCCUPIED_ENABLED, - DoorLock.types.DlUserType.REMOTE_ONLY_USER + DoorLock.types.UserStatusEnum.OCCUPIED_ENABLED, + DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER )) end local function generate_cota_cred_for_device(device) - local len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) or 4 + local len = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) or 6 local cred_data = math.floor(math.random() * (10 ^ len)) cred_data = string.format("%0" .. tostring(len) .. "d", cred_data) device:set_field(lock_utils.COTA_CRED, cred_data, {persist = true}) end -local function lock_state_handler(driver, device, ib, response) - local LockState = DoorLock.attributes.LockState - local attr = capabilities.lock.lock - local LOCK_STATE = { - [LockState.NOT_FULLY_LOCKED] = attr.not_fully_locked(), - [LockState.LOCKED] = attr.locked(), - [LockState.UNLOCKED] = attr.unlocked(), - [UNLATCHED_STATE] = attr.unlocked(), -- Fully unlocked with latch pulled - } +local function apply_cota_credentials_if_absent(device) + if not device:get_field(lock_utils.COTA_CRED) then + -- Process after all other info blocks have been dispatched to ensure MaxPINCodeLength has been processed + device.thread:call_with_delay(0, function(t) + generate_cota_cred_for_device(device) + -- delay needed to allow test to override the random credential data + device.thread:call_with_delay(0, function(t) + -- Attempt to set cota credential at the lowest index + set_cota_credential(device, INITIAL_CREDENTIAL_INDEX) + end) + end) + end +end - if ib.data.value ~= nil then - device:emit_event(LOCK_STATE[ib.data.value]) +local function require_remote_pin_handler(driver, device, ib, response) + if ib.data.value then + apply_cota_credentials_if_absent(device) else - device:emit_event(LOCK_STATE[LockState.NOT_FULLY_LOCKED]) + device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + end +end + +local function max_week_schedule_of_user_handler(driver, device, ib, response) + device:emit_event(capabilities.lockSchedules.weekDaySchedulesPerUser(ib.data.value, {visibility = {displayed = false}})) +end + +local function max_year_schedule_of_user_handler(driver, device, ib, response) + device:emit_event(capabilities.lockSchedules.yearDaySchedulesPerUser(ib.data.value, {visibility = {displayed = false}})) +end + +local function aliro_reader_verification_key_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.readerVerificationKey( + utils.bytes_to_hex_string(ib.data.value), {visibility = {displayed = false}} + )) + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "TRUE", {persist = true}) + end +end + +local function aliro_reader_group_id_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.readerGroupIdentifier( + utils.bytes_to_hex_string(ib.data.value), + {visibility = {displayed = false}} + )) + end +end + +local function aliro_group_resolving_key_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.groupResolvingKey( + utils.bytes_to_hex_string(ib.data.value), + {visibility = {displayed = false}} + )) + end +end + +local function aliro_protocol_versions_handler(driver, device, ib, response) + if ib.data.elements == nil then + return + end + local protocol_versions = {} + for i, element in ipairs(ib.data.elements) do + local ver = string.format("%s.%s", element.value:byte(1), element.value:byte(2)) + table.insert(protocol_versions, ver); + end + device:emit_event(capabilities.lockAliro.expeditedTransactionProtocolVersions(protocol_versions, {visibility = {displayed = false}})) +end + +local function aliro_supported_ble_uwb_protocol_versions_handler(driver, device, ib, response) + if ib.data.elements == nil then + return + end + local protocol_versions = {} + for i, element in ipairs(ib.data.elements) do + local ver = string.format("%s.%s", element.value:byte(1), element.value:byte(2)) + table.insert(protocol_versions, ver); + end + device:emit_event(capabilities.lockAliro.bleUWBProtocolVersions(protocol_versions, {visibility = {displayed = false}})) +end + +local function aliro_ble_advertising_version_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.bleAdvertisingVersion(string.format("%s", ib.data.value), {visibility = {displayed = false}})) + end +end + +local function max_aliro_credential_issuer_key_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.maxCredentialIssuerKeys(ib.data.value, {visibility = {displayed = false}})) + end +end + +local function max_aliro_endpoint_key_handler(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.lockAliro.maxEndpointKeys(ib.data.value, {visibility = {displayed = false}})) + end +end + +local function door_lock_feature_map_handler(driver, device, ib, response) + if ib.data.value == nil then return end + local feature_map = lock_utils.get_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, ib.endpoint_id) or nil + if feature_map ~= ib.data.value then + lock_utils.set_field_for_endpoint(device, lock_utils.LATEST_DOOR_LOCK_FEATURE_MAP, ib.endpoint_id, ib.data.value, { persist = true }) + match_profile(driver, device, true) + end +end + +local function handle_power_source_attribute_list(driver, device, ib, response) + local latest_battery_support = device:get_field(profiling_data.BATTERY_SUPPORT) + for _, attr in ipairs(ib.data.elements or {}) do + if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_PERCENTAGE, {persist=true}) + break -- BATTERY_PERCENTAGE is highest priority. break early if found + elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_LEVEL, {persist=true}) + end + end + -- in the case that 1) no battery has been set, and 2) the returned ib does not include battery attributes, ignore battery + if latest_battery_support == nil and not device:get_field(profiling_data.BATTERY_SUPPORT) then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, {persist=true}) + end + if latest_battery_support == nil or latest_battery_support ~= device:get_field(profiling_data.BATTERY_SUPPORT) then + match_profile(driver, device, false) end end @@ -103,633 +668,2238 @@ local function handle_battery_percent_remaining(driver, device, ib, response) if ib.data.value ~= nil then device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) end -end +end + +local function handle_battery_charge_level(driver, device, ib, response) + if ib.data.value == PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +-- Capability Handler +local function handle_lock(driver, device, command) + local ep = device:component_to_endpoint(command.component) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred then + device:send( + DoorLock.server.commands.LockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.LockDoor(device, ep)) + end +end + +local function handle_unlock(driver, device, command) + local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + local ep = device:component_to_endpoint(command.component) + + if #unbolt_eps > 0 then + if cota_cred then + device:send( + DoorLock.server.commands.UnboltDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.UnboltDoor(device, ep)) + end + else + if cota_cred then + device:send( + DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.UnlockDoor(device, ep)) + end + end +end + +local function handle_unlatch(driver, device, command) + local ep = device:component_to_endpoint(command.component) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred then + device:send( + DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.UnlockDoor(device, ep)) + end +end + +local function add_user_to_table(device, userIdx, userName, userType) + -- Get latest user table + local user_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockUsers.ID, + capabilities.lockUsers.users.NAME, + {} + )) + + -- Add new entry to table + table.insert(user_table, {userIndex = userIdx, userName = userName, userType = userType}) + device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) +end + +local function update_user_in_table(device, userIdx, userName, userType) + -- Get latest user table + local user_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockUsers.ID, + capabilities.lockUsers.users.NAME, + {} + )) + + -- Find user entry + local i = 0 + for index, entry in pairs(user_table) do + if entry.userIndex == userIdx then + i = index + break + end + end + + -- Update user entry + if i ~= 0 then + user_table[i].userType = userType + user_table[i].userName = userName + device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) + end +end + +local function delete_user_from_table(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockUsers.users({}, {visibility = {displayed = false}})) + return + end + + -- Get latest user table + local user_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockUsers.ID, + capabilities.lockUsers.users.NAME, + {} + )) + + -- Remove element from user table + for index, entry in pairs(user_table) do + if entry.userIndex == userIdx then + table.remove(user_table, index) + break + end + end + device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) +end + +local function has_credentials(device, userIdx) + -- Get latest credential table + local cred_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME, + {} + )) + + -- Find credential + for index, entry in pairs(cred_table) do + if entry.userIndex == userIdx then + return true + end + end + + -- Get latest Aliro credential table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Find Aliro credential + for index, entry in pairs(aliro_table) do + if entry.userIndex == userIdx then + return true + end + end + + return false +end + +local function add_credential_to_table(device, userIdx, credIdx, credType) + -- Get latest credential table + local cred_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME, + {} + )) + + -- Add new entry to table + table.insert(cred_table, {userIndex = userIdx, credentialIndex = credIdx, credentialType = credType}) + device:emit_event(capabilities.lockCredentials.credentials(cred_table, {visibility = {displayed = false}})) +end + +local function delete_credential_from_table(device, credIdx) + -- If Credential Index is ALL_INDEX, remove all entries from the table + if credIdx == ALL_INDEX then + device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) + return ALL_INDEX + end + + -- Get latest credential table + local cred_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME, + {} + )) + + -- Delete an entry from credential table + local userIdx = nil + for index, entry in pairs(cred_table) do + if entry.credentialIndex == credIdx then + table.remove(cred_table, index) + userIdx = entry.userIndex + break + end + end + + device:emit_event(capabilities.lockCredentials.credentials(cred_table, {visibility = {displayed = false}})) + return userIdx +end + +local function delete_credential_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) + return + end + + -- Get latest credential table + local cred_table = device:get_latest_state( + "main", + capabilities.lockCredentials.ID, + capabilities.lockCredentials.credentials.NAME + ) or {} + local new_cred_table = {} + + -- Re-create credential table + for index, entry in pairs(cred_table) do + if entry.userIndex ~= userIdx then + table.insert(new_cred_table, entry) + end + end + + device:emit_event(capabilities.lockCredentials.credentials(new_cred_table, {visibility = {displayed = false}})) +end + +local function add_week_schedule_to_table(device, userIdx, scheduleIdx, schedule) + -- Get latest week day schedule table + local week_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.weekDaySchedules.NAME, + {} + )) + + -- Find schedule for specific user + local i = 0 + for index, entry in pairs(week_schedule_table) do + if entry.userIndex == userIdx then + i = index + end + end + + -- Re-create weekDays list + local weekDayList = {} + for _, weekday in ipairs(schedule.weekDays) do + table.insert(weekDayList, weekday) + end + + if i ~= 0 then -- Add schedule for existing user + -- Exclude same scheduleIdx + local new_schedule_table = {} + for index, entry in pairs(week_schedule_table[i].schedules) do + if entry.scheduleIndex ~= scheduleIdx then + table.insert(new_schedule_table, entry) + end + end + -- Add new entry to table + table.insert( + new_schedule_table, + { + scheduleIndex = scheduleIdx, + weekDays = weekDayList, + startHour = schedule.startHour, + startMinute = schedule.startMinute, + endHour = schedule.endHour, + endMinute = schedule.endMinute + } + ) + -- Update schedule for specific user + week_schedule_table[i].schedules = new_schedule_table + else -- Add schedule for new user + table.insert( + week_schedule_table, + { + userIndex = userIdx, + schedules = {{ + scheduleIndex = scheduleIdx, + weekDays = weekDayList, + startHour = schedule.startHour, + startMinute = schedule.startMinute, + endHour = schedule.endHour, + endMinute = schedule.endMinute + }} + } + ) + end + + device:emit_event(capabilities.lockSchedules.weekDaySchedules(week_schedule_table, {visibility = {displayed = false}})) +end + +local function delete_week_schedule_from_table(device, userIdx, scheduleIdx) + -- Get latest week day schedule table + local week_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.weekDaySchedules.NAME, + {} + )) + + -- Find schedule for specific user + local i = 0 + for index, entry in pairs(week_schedule_table) do + if entry.userIndex == userIdx then + i = index + end + end + + -- When there is no userIndex in the table + if i == 0 then + return + end + + -- Re-create schedule table for the user + local new_schedule_table = {} + for index, entry in pairs(week_schedule_table[i].schedules) do + if entry.scheduleIndex ~= scheduleIdx then + table.insert(new_schedule_table, entry) + end + end + + -- If user has no schedule, remove user from the table + if #new_schedule_table == 0 then + table.remove(week_schedule_table, i) + else + week_schedule_table[i].schedules = new_schedule_table + end + + device:emit_event(capabilities.lockSchedules.weekDaySchedules(week_schedule_table, {visibility = {displayed = false}})) +end + +local function delete_week_schedule_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockSchedules.weekDaySchedules({}, {visibility = {displayed = false}})) + return + end + + -- Get latest week day schedule table + local week_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.weekDaySchedules.NAME, + {} + )) + + -- Re-create week day schedule table + local new_week_schedule_table = {} + for index, entry in pairs(week_schedule_table) do + if entry.userIndex ~= userIdx then + table.insert(new_week_schedule_table, entry) + end + end + + device:emit_event(capabilities.lockSchedules.weekDaySchedules(new_week_schedule_table, {visibility = {displayed = false}})) +end + +local function add_year_schedule_to_table(device, userIdx, scheduleIdx, sTime, eTime) + -- Get latest year day schedule table + local year_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.yearDaySchedules.NAME, + {} + )) + + -- Find schedule for specific user + local i = 0 + for index, entry in pairs(year_schedule_table) do + if entry.userIndex == userIdx then + i = index + end + end + + if i ~= 0 then -- Add schedule for existing user + -- Exclude same scheduleIdx + local new_schedule_table = {} + for index, entry in pairs(year_schedule_table[i].schedules) do + if entry.scheduleIndex ~= scheduleIdx then + table.insert(new_schedule_table, entry) + end + end + -- Add new entry to table + table.insert( + new_schedule_table, + { + scheduleIndex = scheduleIdx, + localStartTime = sTime, + localEndTime = eTime + } + ) + -- Update schedule for specific user + year_schedule_table[i].schedules = new_schedule_table + else -- Add schedule for new user + table.insert( + year_schedule_table, + { + userIndex = userIdx, + schedules = {{ + scheduleIndex = scheduleIdx, + localStartTime = sTime, + localEndTime = eTime + }} + } + ) + end + + device:emit_event(capabilities.lockSchedules.yearDaySchedules(year_schedule_table, {visibility = {displayed = false}})) +end + +local function delete_year_schedule_from_table(device, userIdx, scheduleIdx) + -- Get latest year day schedule table + local year_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.yearDaySchedules.NAME, + {} + )) + + -- Find schedule for specific user + local i = 0 + for index, entry in pairs(year_schedule_table) do + if entry.userIndex == userIdx then + i = index + end + end + + -- When there is no userIndex in the table + if i == 0 then + return + end + + -- Re-create year day schedule table for the user + local new_schedule_table = {} + for index, entry in pairs(year_schedule_table[i].schedules) do + if entry.scheduleIndex ~= scheduleIdx then + table.insert(new_schedule_table, entry) + end + end + + -- If user has no schedule, remove user from the table + if #new_schedule_table == 0 then + table.remove(year_schedule_table, i) + else + year_schedule_table[i].schedules = new_schedule_table + end + + device:emit_event(capabilities.lockSchedules.yearDaySchedules(year_schedule_table, {visibility = {displayed = false}})) +end + +local function delete_year_schedule_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockSchedules.yearDaySchedules({}, {visibility = {displayed = false}})) + return + end + + -- Get latest year day schedule table + local year_schedule_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockSchedules.ID, + capabilities.lockSchedules.yearDaySchedules.NAME, + {} + )) + + -- Re-create year day schedule table + local new_year_schedule_table = {} + for index, entry in pairs(year_schedule_table) do + if entry.userIndex ~= userIdx then + table.insert(new_year_schedule_table, entry) + end + end + + device:emit_event(capabilities.lockSchedules.yearDaySchedules(new_year_schedule_table, {visibility = {displayed = false}})) +end + +local function add_aliro_to_table(device, userIdx, keyIdx, keyType, keyId) + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Add new entry to table + table.insert(aliro_table, {userIndex = userIdx, keyIndex = keyIdx, keyType = keyType, keyId = keyId}) + device:emit_event(capabilities.lockAliro.credentials(aliro_table, {visibility = {displayed = false}})) +end + +local function delete_aliro_from_table(device, userIdx, keyType, keyId) + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Delete an entry from aliro table + if keyType == "issuerKey" then + for i, entry in pairs(aliro_table) do + if entry.userIndex == userIdx then + table.remove(aliro_table, i) + break + end + end + else + for i, entry in pairs(aliro_table) do + if entry.userIndex == userIdx and entry.keyId == keyId then + table.remove(aliro_table, i) + break + end + end + end + device:emit_event(capabilities.lockAliro.credentials(aliro_table, {visibility = {displayed = false}})) +end + +local function delete_aliro_from_table_as_user(device, userIdx) + -- If User Index is ALL_INDEX, remove all entry from the table + if userIdx == ALL_INDEX then + device:emit_event(capabilities.lockAliro.credentials({}, {visibility = {displayed = false}})) + return + end + + -- Get latest credential table + local aliro_table = device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME + ) or {} + local new_aliro_table = {} + + -- Re-create credential table + for index, entry in pairs(aliro_table) do + if entry.userIndex ~= userIdx then + table.insert(new_aliro_table, entry) + end + end + + device:emit_event(capabilities.lockAliro.credentials(new_aliro_table, {visibility = {displayed = false}})) +end + +local function handle_add_user(driver, device, command) + -- Get parameters + local cmdName = "addUser" + local userName = command.args.userName + local userType = command.args.userType + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) + device:set_field(lock_utils.USER_NAME, userName, {persist = true}) + device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) + + -- Get available user index + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.GetUser(device, ep, INITIAL_CREDENTIAL_INDEX)) +end + +local function handle_update_user(driver, device, command) + -- Get parameters + local cmdName = "updateUser" + local userIdx = command.args.userIndex + local userName = command.args.userName + local userNameMatter = string.sub(userName, 1, MAX_USER_NAME_LENGTH) + local userType = command.args.userType + local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + if userType == "guest" then + userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER + end + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.USER_NAME, userName, {persist = true}) + device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetUser( + device, ep, + DoorLock.types.DataOperationTypeEnum.MODIFY, + userIdx, + userNameMatter, + nil, -- Unique ID + nil, -- User Status + userTypeMatter, + nil -- Credential Rule + ) + ) +end + +local function get_user_response_handler(driver, device, ib, response) + local elements = ib.info_block.data.elements + local userIdx = elements.user_index.value + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + if status ~= "success" then + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + statusCode = status + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + local ep = find_default_endpoint(device, DoorLock.ID) + 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, + capabilities.lockUsers.totalUsersSupported.NAME + ) or 10 + -- Found available user index + if status == nil or status == DoorLock.types.UserStatusEnum.AVAILABLE then + local userName = device:get_field(lock_utils.USER_NAME) + local userNameMatter = string.sub(userName, 1, MAX_USER_NAME_LENGTH) + local userType = device:get_field(lock_utils.USER_TYPE) + local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + if userType == "guest" then + userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER + end + + -- Save values to field + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + + -- Send command + device:send( + DoorLock.server.commands.SetUser( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, + userIdx, + userNameMatter, + nil, -- Unique ID + nil, -- User Status + userTypeMatter, + nil -- Credential Rule + ) + ) + elseif userIdx >= maxUser then -- There's no available user index + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + statusCode = "resourceExhausted" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + else -- Check next user index + device:send(DoorLock.server.commands.GetUser(device, ep, userIdx + 1)) + end +end + +local function set_user_response_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userName = device:get_field(lock_utils.USER_NAME) + local userType = device:get_field(lock_utils.USER_TYPE) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.OCCUPIED then + status = "occupied" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + + -- Update User in table + if status == "success" then + if cmdName == "addUser" then + add_user_to_table(device, userIdx, userName, userType) + elseif cmdName == "updateUser" then + update_user_in_table(device, userIdx, userName, userType) + end + else + device.log.warn(string.format("Failed to set user: %s", status)) + end + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + statusCode = status + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) +end + +local function handle_delete_user(driver, device, command) + -- Get parameters + local cmdName = "deleteUser" + local userIdx = command.args.userIndex + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearUser(device, ep, userIdx)) +end + +local function handle_delete_all_users(driver, device, command) + -- Get parameters + local cmdName = "deleteAllUsers" + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, ALL_INDEX, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearUser(device, ep, ALL_INDEX)) +end + +local function clear_user_response_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + + -- Delete User and Credential from table + if status == "success" then + delete_user_from_table(device, userIdx) + delete_credential_from_table_as_user(device, userIdx) + delete_aliro_from_table_as_user(device, userIdx) + delete_week_schedule_from_table_as_user(device, userIdx) + delete_year_schedule_from_table_as_user(device, userIdx) + else + device.log.warn(string.format("Failed to clear user: %s", status)) + end + + -- In the "defaultSchedule" cmd failure path, when a guest user's credentials are set but the scheduling + -- fails during default setup, those credentials should be removed, so we wait to log lock credentials until here. + if cmdName == "defaultSchedule" then + -- note: if clear user succeeds, we'd log credential settings as a "failure" since it's effectively a no-op. + -- If clear user fails, log "success" since the credentials would still be present. + local lock_credential_status = status == "success" and "failure" or "success" + local command_result_info = { + commandName = "addCredential", + userIndex = userIdx, + statusCode = lock_credential_status + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + else + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + statusCode = status + } + device:emit_event(capabilities.lockUsers.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + end + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) +end + +local function handle_add_credential(driver, device, command) + -- Get parameters + local cmdName = "addCredential" + local userIdx = command.args.userIndex + local userType = command.args.userType + local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + if userType == "guest" then + userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER + end + if userIdx == 0 then + userIdx = nil + else + userTypeMatter = nil + end + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = INITIAL_CREDENTIAL_INDEX + } + local credData = command.args.credentialData + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) + device:set_field(lock_utils.CRED_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) + device:set_field(lock_utils.CRED_DATA, credData, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + credData, -- Credential Data + userIdx, -- User Index + nil, -- User Status + userTypeMatter -- User Type + ) + ) +end + +local function handle_update_credential(driver, device, command) + -- Get parameters + local cmdName = "updateCredential" + local userIdx = command.args.userIndex + local credIdx = command.args.credentialIndex + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = credIdx + } + local credData = command.args.credentialData + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) + device:set_field(lock_utils.USER_TYPE, nil, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.MODIFY, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + credData, -- Credential Data + userIdx, -- User Index + nil, -- User Status + nil -- User Type + ) + ) +end + +local function set_pin_response_handler(driver, device, ib, response) + if ib.status ~= im.InteractionResponse.Status.SUCCESS then + device.log.error("Failed to set credential for device") + return + end + + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local credData = device:get_field(lock_utils.CRED_DATA) + if cmdName == "addCota" then + credData = device:get_field(lock_utils.COTA_CRED) + end + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userType = device:get_field(lock_utils.USER_TYPE) + local credIdx = device:get_field(lock_utils.CRED_INDEX) + local elements = ib.info_block.data.elements + local status = RESPONSE_STATUS_MAP[elements.status.value] + + if status == "success" then + -- Don't save user and credential for COTA + if cmdName == "addCota" then + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + -- 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 + if cmdName == "addCredential" then + add_credential_to_table(device, userIdx, credIdx, "pin") + end + + -- If User Type is Guest and device support schedule, add default schedule + local week_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES}) + local year_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES}) + if userType == "guest" and (#week_schedule_eps > 0 or #year_schedule_eps > 0) and cmdName ~= "updateCredential" then + local cmdName = "defaultSchedule" + local scheduleIdx = 1 + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + + local ep = device:component_to_endpoint("main") + device:send( + DoorLock.server.commands.SetYearDaySchedule( + device, ep, + scheduleIdx, + userIdx, + lock_utils.MIN_EPOCH_S, + lock_utils.MAX_EPOCH_S + ) + ) + else + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + credentialIndex = credIdx, + statusCode = status + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + end + return + end + + -- In the case DlStatus returns Occupied, this means the current credential index is in use, + -- so we must try the next one. If there is not a next index (i.e. it is nil), + -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. + device.log.warn(string.format("Failed to set credential: %s", status)) + if status == "occupied" and elements.next_credential_index.value == nil then + local command_result_info = { + commandName = cmdName, + statusCode = "resourceExhausted" -- No more available credential index + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + elseif status == "occupied" then + -- Get parameters + local credIdx = elements.next_credential_index.value + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = credIdx, + } + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userType = device:get_field(lock_utils.USER_TYPE) + local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + if userIdx ~= nil then + userTypeMatter = nil + elseif userType == "guest" then + userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER + elseif userType == "remote" then + userTypeMatter = DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER + end + + device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) + + -- Send command + local ep = find_default_endpoint(device, DoorLock.ID) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + credData, -- Credential Data + userIdx, -- User Index + nil, -- User Status + userTypeMatter -- User Type + ) + ) + elseif status == "duplicate" and cmdName == "addCota" then + generate_cota_cred_for_device(device) + device.thread:call_with_delay(0, function(t) set_cota_credential(device, credIdx) end) + else + local command_result_info = { + commandName = cmdName, + statusCode = status + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + end +end + +local function set_issuer_key_response_handler(driver, device, ib, response) + local cmdName = "setIssuerKey" + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + local issuerKeyIndex = device:get_field(lock_utils.ISSUER_KEY_INDEX) + local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) + local elements = ib.info_block.data.elements + local status = RESPONSE_STATUS_MAP[elements.status.value] + + if status == "success" then + -- Delete field data + device:set_field(lock_utils.ISSUER_KEY, nil, {persist = true}) + + -- If user is added also, update User table + if userIdx == nil then + userIdx = elements.user_index.value + add_user_to_table(device, userIdx, nil, "adminMember") + end + + -- Update Aliro table + add_aliro_to_table(device, userIdx, issuerKeyIndex, "issuerKey", nil) + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + -- In the case DlStatus returns Occupied, this means the current credential index is in use, + -- so we must try the next one. If there is not a next index (i.e. it is nil), + -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. + device.log.warn(string.format("Failed to set credential: %s", status)) + if status == "occupied" and elements.next_credential_index.value == nil then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = "resourceExhausted" -- No more available credential index + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + elseif status == "occupied" then + -- Get parameters + if userIdx ~= nil then + userType = nil + end + local credIdx = elements.next_credential_index.value + local credType = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY + local credData = device:get_field(lock_utils.ISSUER_KEY) + local credential = { + credential_type = credType, + credential_index = credIdx + } + + -- Save values to field + device:set_field(lock_utils.ISSUER_KEY_INDEX, credIdx, {persist = true}) + + -- Send command + local ep = find_default_endpoint(device, DoorLock.ID) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, + credential, -- Credential + lock_utils.hex_string_to_octet_string(credData), -- Credential Data + userIdx, -- User Index + nil, -- User Status + userType -- User Type + ) + ) + else + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + end +end + +local function set_endpoint_key_response_handler(driver, device, ib, response) + local cmdName = "setEndpointKey" + local userIdx = device:get_field(lock_utils.USER_INDEX) + local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + local keyId = device:get_field(lock_utils.DEVICE_KEY_ID) + local keyType = device:get_field(lock_utils.ENDPOINT_KEY_TYPE) + local endpointKeyIndex = device:get_field(lock_utils.ENDPOINT_KEY_INDEX) + local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) + local elements = ib.info_block.data.elements + local status = RESPONSE_STATUS_MAP[elements.status.value] + + if status == "success" then + -- Delete field data + device:set_field(lock_utils.ENDPOINT_KEY, nil, {persist = true}) + + -- If user is added also, update User table + if userIdx == nil then + userIdx = elements.user_index.value + add_user_to_table(device, userIdx, nil, "adminMember") + end + + -- Update Aliro table + add_aliro_to_table(device, userIdx, endpointKeyIndex, keyType, keyId) + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + -- In the case DlStatus returns Occupied, this means the current credential index is in use, + -- so we must try the next one. If there is not a next index (i.e. it is nil), + -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. + device.log.warn(string.format("Failed to set credential: %s", status)) + + if status == "occupied" and elements.next_credential_index.value == nil then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = "resourceExhausted" -- No more available credential index + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + elseif status == "occupied" then + -- Get parameters + if userIdx ~= nil then + userType = nil + end + local credIdx = elements.next_credential_index.value + local credType = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType] + local credData = device:get_field(lock_utils.ENDPOINT_KEY) + local credential = { + credential_type = credType, + credential_index = credIdx + } + + -- Save values to field + device:set_field(lock_utils.ENDPOINT_KEY_INDEX, credIdx, {persist = true}) + + -- Send command + local ep = find_default_endpoint(device, DoorLock.ID) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, + credential, -- Credential + lock_utils.hex_string_to_octet_string(credData), -- Credential Data + userIdx, -- User Index + nil, -- User Status + userType -- User Type + ) + ) + else + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + end +end + +local function set_credential_response_handler(driver, device, ib, response) + if ib.status ~= im.InteractionResponse.Status.SUCCESS then + device.log.error("Failed to set credential for device") + return + end + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + if cmdName == "addCredential" or cmdName == "updateCredential" or cmdName == "addCota" then + set_pin_response_handler(driver, device, ib, response) + elseif cmdName == "setIssuerKey" then + set_issuer_key_response_handler(driver, device, ib, response) + elseif cmdName == "setEndpointKey" then + set_endpoint_key_response_handler(driver, device, ib, response) + end +end + +local function handle_delete_credential(driver, device, command) + -- Get parameters + local cmdName = "deleteCredential" + local credIdx = command.args.credentialIndex + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = credIdx, + } + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) +end + +local function handle_delete_all_credentials(driver, device, command) + -- Get parameters + local cmdName = "deleteAllCredentials" + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.PIN, + credential_index = ALL_INDEX, + } + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.CRED_INDEX, ALL_INDEX, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) +end + +local function clear_credential_response_handler(driver, device, ib, response) + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + if cmdName ~= "deleteCredential" and cmdName ~= "clearEndpointKey" and + cmdName ~= "clearIssuerKey" and cmdName ~= "deleteAllCredentials" then + return + end + local status = RESPONSE_STATUS_MAP[ib.status] or "success" + local command_result_info = { commandName = cmdName, statusCode = status } -- default command result + local userIdx = device:get_field(lock_utils.USER_INDEX) + local all_user_credentials_removed = false + + if (cmdName == "deleteCredential" or cmdName == "deleteAllCredentials") and status == "success" then + -- Get result from data saved in relevant, associated fields + local credIdx = device:get_field(lock_utils.CRED_INDEX) + + -- find userIdx associated with credIdx, don't use lock utils field in this case + userIdx = delete_credential_from_table(device, credIdx) + if userIdx ~= nil then + all_user_credentials_removed = not has_credentials(device, userIdx) + end + + -- set unique command result fields + command_result_info.userIndex = userIdx + command_result_info.credentialIndex = credIdx + elseif cmdName == "clearIssuerKey" and status == "success" then + -- Get result from data saved in relevant, associated fields + local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) + + delete_aliro_from_table(device, userIdx, "issuerKey", nil) + all_user_credentials_removed = not has_credentials(device, userIdx) + + -- set unique command result fields + command_result_info.userIndex = userIdx + command_result_info.requestId = reqId + elseif cmdName == "clearEndpointKey" and status == "success" then + -- Get result from data saved in relevant, associated fields + local deviceKeyId = device:get_field(lock_utils.DEVICE_KEY_ID) + local keyType = device:get_field(lock_utils.ENDPOINT_KEY_TYPE) + local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) + + delete_aliro_from_table(device, userIdx, keyType, deviceKeyId) + all_user_credentials_removed = not has_credentials(device, userIdx) + + -- set unique command result fields + command_result_info.userIndex = userIdx + command_result_info.keyId = deviceKeyId + command_result_info.requestId = reqId + end -local function handle_battery_charge_level(driver, device, ib, response) - if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then - device:emit_event(capabilities.batteryLevel.battery.normal()) - elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then - device:emit_event(capabilities.batteryLevel.battery.warning()) - elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then - device:emit_event(capabilities.batteryLevel.battery.critical()) + -- user data if credentials were removed + if all_user_credentials_removed then + delete_user_from_table(device, userIdx) + delete_week_schedule_from_table_as_user(device, userIdx) + delete_year_schedule_from_table_as_user(device, userIdx) end -end -local function handle_power_source_attribute_list(driver, device, ib, response) - local support_battery_percentage = false - local support_battery_level = false - for _, attr in ipairs(ib.data.elements) do - -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or - -- BatChargeLevel (Attribute ID 0x0E) is present. - if attr.value == 0x0C then - support_battery_percentage = true - elseif attr.value == 0x0E then - support_battery_level = true - end - end - local profile_name = device:get_field(PROFILE_BASE_NAME) - if profile_name ~= nil then - if not support_battery_percentage then - if support_battery_level then - profile_name = profile_name .. "-batteryLevel" - else - profile_name = profile_name .. "-nobattery" - end - end - device.log.info(string.format("Updating device profile to %s.", profile_name)) - device:try_update_metadata({profile = profile_name}) + -- Update commandResult + if cmdName == "deleteCredential" or cmdName == "deleteAllCredentials" then + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + else + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) end + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end -local function max_pin_code_len_handler(driver, device, ib, response) - device:emit_event(capabilities.lockCodes.maxCodeLength(ib.data.value, {visibility = {displayed = false}})) -end +local function handle_set_week_day_schedule(driver, device, command) + -- Get parameters + local cmdName = "setWeekDaySchedule" + local scheduleIdx = command.args.scheduleIndex + local userIdx = command.args.userIndex + local schedule = command.args.schedule + local wDays = {} + local scheduleBit = 0 + for _, weekDay in ipairs(schedule.weekDays) do + scheduleBit = scheduleBit + WEEK_DAY_MAP[weekDay] + table.insert(wDays, weekDay) + end + local startHour = schedule.startHour + local startMinute = schedule.startMinute + local endHour = schedule.endHour + local endMinute = schedule.endMinute -local function min_pin_code_len_handler(driver, device, ib, response) - device:emit_event(capabilities.lockCodes.minCodeLength(ib.data.value, {visibility = {displayed = false}})) -end + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end -local function num_pin_users_handler(driver, device, ib, response) - device:set_field(lock_utils.TOTAL_PIN_USERS, ib.data.value) - device:emit_event(capabilities.lockCodes.maxCodes(ib.data.value, {visibility = {displayed = false}})) + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_WEEK_DAYS, wDays, {persist = true}) + device:set_field(lock_utils.SCHEDULE_START_HOUR, startHour, {persist = true}) + device:set_field(lock_utils.SCHEDULE_START_MINUTE, startMinute, {persist = true}) + device:set_field(lock_utils.SCHEDULE_END_HOUR, endHour, {persist = true}) + device:set_field(lock_utils.SCHEDULE_END_MINUTE, endMinute, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetWeekDaySchedule( + device, ep, + scheduleIdx, -- Week Day Schedule Index + userIdx, -- User Index + scheduleBit, -- Days Mask + startHour, -- Start Hour + startMinute, -- Start Minute + endHour, -- End Hour + endMinute -- End Minute + ) + ) end -local function apply_cota_credentials_if_absent(device) - if not device:get_field(lock_utils.COTA_CRED) then - --Process after all other info blocks have been dispatched to ensure MaxPINCodeLength has been processed - device.thread:call_with_delay(0, function(t) - generate_cota_cred_for_device(device) - -- delay needed to allow test to override the random credential data - device.thread:call_with_delay(0, function(t) - -- Attempt to set cota credential at the lowest index - set_cota_credential(device, INITIAL_COTA_INDEX) - end) - end) +local function set_week_day_schedule_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) + local days = device:get_field(lock_utils.SCHEDULE_WEEK_DAYS) + local sHour = device:get_field(lock_utils.SCHEDULE_START_HOUR) + local sMinute = device:get_field(lock_utils.SCHEDULE_START_MINUTE) + local eHour = device:get_field(lock_utils.SCHEDULE_END_HOUR) + local eMinute = device:get_field(lock_utils.SCHEDULE_END_MINUTE) + local schedule = { + weekDays = days, + startHour = sHour, + startMinute = sMinute, + endHour = eHour, + endMinute = eMinute + } + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" end -end -local function require_remote_pin_handler(driver, device, ib, response) - if ib.data.value then - apply_cota_credentials_if_absent(device) + -- Add Week Day Schedule to table + if status == "success" then + add_week_schedule_to_table(device, userIdx, scheduleIdx, schedule) else - device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + device.log.warn(string.format("Failed to set week day schedule: %s", status)) end + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + scheduleIndex = scheduleIdx, + statusCode = status + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end -local function clear_credential_response_handler(driver, device, ib, response) - local deleted_code_slot = device:get_field(lock_utils.DELETING_CODE) - if deleted_code_slot == nil and ib.status == im.InteractionResponse.Status.SUCCESS then - device.log.debug("Cleared space in lock credential db for COTA credential") +local function handle_clear_week_day_schedule(driver, device, command) + -- Get parameters + local cmdName = "clearWeekDaySchedules" + local scheduleIdx = command.args.scheduleIndex + local userIdx = command.args.userIndex + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) return end - if ib.status == im.InteractionResponse.Status.SUCCESS then - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, tostring(deleted_code_slot))) - --make sure cota credential exists if the user deletes it or if space was created for the COTA cred - if deleted_code_slot == device:get_field(lock_utils.COTA_CRED_INDEX) or - device:get_field(lock_utils.NONFUNCTIONAL) then - set_cota_credential(device, device:get_field(lock_utils.COTA_CRED_INDEX) or INITIAL_COTA_INDEX) - end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearWeekDaySchedule(device, ep, scheduleIdx, userIdx)) +end + +local function clear_week_day_schedule_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + + -- Delete Week Day Schedule to table + if status == "success" then + delete_week_schedule_from_table(device, userIdx, scheduleIdx) else - device.log.error(string.format("Failed to delete code slot %s", deleted_code_slot)) + device.log.warn(string.format("Failed to clear week day schedule: %s", status)) end - device:set_field(lock_utils.DELETING_CODE, nil) + + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + scheduleIndex = scheduleIdx, + statusCode = status + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end -local function set_credential_response_handler(driver, device, ib, response) - if ib.status ~= im.InteractionResponse.Status.SUCCESS then - device.log.error("Failed to set code for device") +local function handle_set_year_day_schedule(driver, device, command) + -- Get parameters + local cmdName = "setYearDaySchedule" + local scheduleIdx = command.args.scheduleIndex + local userIdx = command.args.userIndex + local localStartTime = command.args.schedule.localStartTime + local localEndTime = command.args.schedule.localEndTime + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) return end - local elements = ib.info_block.data.elements - local credential_index = device:get_field(lock_utils.SET_CREDENTIAL) - device:set_field(lock_utils.SET_CREDENTIAL, nil) - if credential_index == nil then - device.log.error("Received unexpected SetCredentialResponse") - return - end - local code_slot = tostring(credential_index) - local status = elements.status.value - if status == DoorLock.types.DlStatus.SUCCESS then - local event = capabilities.lockCodes.codeChanged("", {state_change = true}) - local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) - local code_name = (credential_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or - lock_utils.get_code_name(device, code_slot) - event.data = {codeName = code_name} - event.value = lock_utils.get_change_type(device, tostring(code_slot)) - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = event.data.codeName - device:emit_event(event) - if credential_index == cota_cred_index then - device:emit_event( - capabilities.lockCodes.codeChanged( - code_slot .. " renamed", {state_change = true} - ) - ) - end - lock_utils.lock_codes_event(device, lock_codes) - lock_utils.reset_code_state(device, code_slot) - if device:get_field(lock_utils.NONFUNCTIONAL) and cota_cred_index == credential_index then - device.log.info("Successfully set COTA credential after being non-functional") - device:set_field(lock_utils.NONFUNCTIONAL, false, {persist = true}) - local profile_name = "base-lock" - device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) - local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) - if #battery_feature_eps == 0 then - profile_name = profile_name .. "-nobattery" - device.log.info(string.format("Updating device profile to %s.", profile_name)) - device:try_update_metadata({profile = profile_name, provisioning_state = "PROVISIONED"}) - else - local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) - req:merge(clusters.PowerSource.attributes.AttributeList:read()) - device:send(req) - end - end - elseif device:get_field(lock_utils.COTA_CRED) and credential_index == device:get_field(lock_utils.COTA_CRED_INDEX) then - -- Handle failure to set a COTA credential - if status == DoorLock.types.DlStatus.OCCUPIED and elements.next_credential_index.value ~= nil then - --This credential index is unavailable, but there is another available - set_cota_credential(device, elements.next_credential_index.value) - elseif status == DoorLock.types.DlStatus.OCCUPIED and - elements.next_credential_index.value == nil and - credential_index == INITIAL_COTA_INDEX then - --There are no credential indices available on the device - device.log.error("Device requires COTA credential, but has no credential indexes available!") - device.log.error("Lock and Unlock commands will no longer work!!") - device:set_field(PROFILE_BASE_NAME, "nonfunctional-lock", {persist = true}) - device:try_update_metadata({profile = "nonfunctional-lock", provisioning_state = "NONFUNCTIONAL"}) - device:set_field(lock_utils.NONFUNCTIONAL, true, {persist = true}) - elseif status == DoorLock.types.DlStatus.OCCUPIED and elements.next_credential_index.value == nil then - --There are no credential indices available, but we must ensure we search all indices. - set_cota_credential(device, INITIAL_COTA_INDEX) - elseif status == DoorLock.types.DlStatus.DUPLICATE then - --The credential we randomly generated already exists - generate_cota_cred_for_device(device) - --delay 0 needed for unit test verification of random value - device.thread:call_with_delay(0, function(t) set_cota_credential(device, credential_index) end) - elseif status == DoorLock.types.DlStatus.INVALID_FIELD then - device.log.error("Invalid SetCredential command sent to set a COTA credential. This is a bug.") - elseif elements.next_credential_index.value ~= nil then - device.log.warn(string.format( - "Received non-success SetCredentialResponse status (%s), but there is a next credential index available", elements.status - )) - set_cota_credential(device, elements.next_credential_index.value) - end - else - device.log.error( - string.format( - "Failed to set user code for device, SetCredential status received: %s", elements.status - ) + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + device:set_field(lock_utils.SCHEDULE_LOCAL_START_TIME, localStartTime, {persist = true}) + device:set_field(lock_utils.SCHEDULE_LOCAL_END_TIME, localEndTime, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetYearDaySchedule( + device, ep, + scheduleIdx, + userIdx, + lock_utils.iso8601_to_epoch(localStartTime), + lock_utils.iso8601_to_epoch(localEndTime) ) - end + ) end -local function get_credential_status_response_handler(driver, device, ib, response) - if ib.status ~= im.InteractionResponse.Status.SUCCESS then - device.log.warn("Not taking action on GetCredentialStatusResponse because failed status") +local function set_year_day_schedule_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local userIdx = device:get_field(lock_utils.USER_INDEX) + local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) + local localStartTime = device:get_field(lock_utils.SCHEDULE_LOCAL_START_TIME) + local localEndTime = device:get_field(lock_utils.SCHEDULE_LOCAL_END_TIME) + local status = "success" + if ib.status == DoorLock.types.DlStatus.FAILURE then + status = "failure" + elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" end - local cred_index = device:get_field(lock_utils.CHECKING_CREDENTIAL) - if cred_index == nil then - device.log.warn("Received unexpected CredentialStatusResponse") + + if cmdName == "defaultSchedule" then + local cmdName = "addCredential" + local credIdx = device:get_field(lock_utils.CRED_INDEX) + + if status == "success" then + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + credentialIndex = credIdx, + statusCode = status + } + device:emit_event(capabilities.lockCredentials.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + else + local ep = find_default_endpoint(device, clusters.DoorLock.ID) + device:send(DoorLock.server.commands.ClearUser(device, ep, userIdx)) + end return end - local elements = ib.info_block.data.elements - local credential_exists = elements.credential_exists.value - local next_credential_index = elements.next_credential_index and elements.next_credential_index.value or nil - - local event = capabilities.lockCodes.codeChanged("", {state_change = true}) - local code_slot = tostring(cred_index) - local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) - local code_name = (cred_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or lock_utils.get_code_name(device, code_slot) - event.data = {codeName = code_name} - if credential_exists then - -- Code slot is occupied - event.value = lock_utils.get_change_type(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = event.data.codeName - device:emit_event(event) - lock_utils.lock_codes_event(device, lock_codes) - lock_utils.reset_code_state(device, code_slot) - else - -- Code slot is unoccupied - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - -- Code has been deleted - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - if cred_index == cota_cred_index then --make sure cota credential exists if it was deleted - set_cota_credential(device, INITIAL_COTA_INDEX) - end - else - -- Code is unset - event.value = code_slot .. " unset" - device:emit_event(event) + + if status == "success" then + if cmdName == "setYearDaySchedule" then + add_year_schedule_to_table(device, userIdx, scheduleIdx, localStartTime, localEndTime) + elseif cmdName == "clearYearDaySchedules" then + delete_year_schedule_from_table(device, userIdx, scheduleIdx) end + else + device.log.warn(string.format("Failed to set/clear year day schedule: %s", status)) end - device:set_field(lock_utils.CHECKING_CREDENTIAL, nil) - local is_scanning = device:get_latest_state( - "main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME - ) == "Scanning" - if not is_scanning then + -- Update commandResult + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + scheduleIndex = scheduleIdx, + statusCode = status + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) +end + +local function handle_clear_year_day_schedule(driver, device, command) + -- Get parameters + local cmdName = "clearYearDaySchedules" + local scheduleIdx = command.args.scheduleIndex + local userIdx = command.args.userIndex + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockSchedules.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) return end - if (next_credential_index == nil) then - device:emit_event( - capabilities.lockCodes.scanCodes( - "Complete", {visibility = {displayed = false}} - ) - ) - local lock_codes = lock_utils.get_lock_codes(device) - lock_utils.lock_codes_event(device, lock_codes) - elseif next_credential_index ~= nil then - device:set_field(lock_utils.CHECKING_CREDENTIAL, next_credential_index) - device:send( - DoorLock.server.commands.GetCredentialStatus( - device, - ib.info_block.endpoint_id, - {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = device:get_field(lock_utils.CHECKING_CREDENTIAL)} - ) + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + + -- Send command + -- In SmartThings, Schedule Restrict User is basically allowed to access always. + -- So, if user delete the year day schedule, enter an infinitely long schedule. + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetYearDaySchedule( + device, ep, + scheduleIdx, + userIdx, + lock_utils.MIN_EPOCH_S, + lock_utils.MAX_EPOCH_S ) - end + ) end local function alarm_event_handler(driver, device, ib, response) local DlAlarmCode = DoorLock.types.DlAlarmCode local alarm_code = ib.data.elements.alarm_code - if alarm_code.value == DlAlarmCode.FRONT_ESCEUTCHEON_REMOVED or alarm_code.value - == DlAlarmCode.WRONG_CODE_ENTRY_LIMIT or alarm_code.value == DlAlarmCode.FORCED_USER - or alarm_code.value == DlAlarmCode.DOOR_FORCED_OPEN then - device:emit_event(capabilities.tamperAlert.tamper.detected()) + if alarm_code.value == DlAlarmCode.LOCK_JAMMED then + device:emit_event(capabilities.lockAlarm.alarm.unableToLockTheDoor({state_change = true})) + elseif alarm_code.value == DlAlarmCode.LOCK_FACTORY_RESET then + device:emit_event(capabilities.lockAlarm.alarm.lockFactoryReset({state_change = true})) + elseif alarm_code.value == DlAlarmCode.WRONG_CODE_ENTRY_LIMIT then + device:emit_event(capabilities.lockAlarm.alarm.attemptsExceeded({state_change = true})) + elseif alarm_code.value == DlAlarmCode.FRONT_ESCEUTCHEON_REMOVED then + device:emit_event(capabilities.lockAlarm.alarm.damaged({state_change = true})) + elseif alarm_code.value == DlAlarmCode.DOOR_FORCED_OPEN then + device:emit_event(capabilities.lockAlarm.alarm.forcedOpeningAttempt({state_change = true})) end end local function lock_op_event_handler(driver, device, ib, response) - local tamper_detected = device:get_latest_state( - device:endpoint_to_component(ib.endopint_id), - capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME - ) - if nil == tamper_detected or tamper_detected == capabilities.tamperAlert.tamper.detected.NAME then - device:emit_event(capabilities.tamperAlert.tamper.clear()) - end -end - -local function lock_user_change_event_handler(driver, device, ib, response) - local event = capabilities.lockCodes.codeChanged("", {state_change = true}) - local elements = ib.data.elements - local data_type_changed = elements.lock_data_type.value - local operation_type = elements.data_operation_type.value - local user_index = elements.user_index.value - local data_index = elements.data_index and elements.data_index.value - local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) - - if data_type_changed == DoorLock.types.DlLockDataType.PIN then -- pin added or removed - local code_slot = data_index and tostring(data_index) or nil - if (operation_type == DoorLock.types.DlDataOperationType.ADD or operation_type - == DoorLock.types.DlDataOperationType.MODIFY) and code_slot ~= nil then - local change_type = lock_utils.get_change_type(device, code_slot) - event.value = change_type - local code_name = (data_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or lock_utils.get_code_name(device, code_slot) - event.data = {codeName = code_name} - device:emit_event(event) - if string.match(change_type, "%d+ set") ~= nil then - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = code_name - lock_utils.lock_codes_event(device, lock_codes) - end - elseif operation_type == DoorLock.types.DlDataOperationType.CLEAR and code_slot ~= nil then - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, tostring(code_slot))) - --make sure cota credential is created if the user deletes it or a space is made for it - if data_index == cota_cred_index or device:get_field(lock_utils.NONFUNCTIONAL) then - set_cota_credential(device, cota_cred_index or INITIAL_COTA_INDEX) - end - else -- invalid event because no credential index - device.log.error( - "Received unhandled LockUserChangeEvent because it didn't affect a PIN credential" - ) - end - elseif data_type_changed == DoorLock.types.DlLockDataType.USER_INDEX and operation_type - == DoorLock.types.DlDataOperationType.CLEAR then - if user_index == 0xFFFE then - device.log.warn("All users were cleared by another fabric") -- we never do this - for cs, _ in pairs(lock_utils.get_lock_codes(device)) do - lock_utils.code_deleted(device, cs) - end - lock_utils.lock_codes_event(device, {}) - if device:get_field(lock_utils.COTA_CRED) ~= nil then set_cota_credential(device, INITIAL_COTA_INDEX) end - else - device.log.info("Not handling LockUserChange event") - end - -- Note when a Lock User is deleted, the credentials associated with that user are also deleted. - -- Change events are created for each credential as well as the user. + local opType = ib.data.elements.lock_operation_type + local opSource = ib.data.elements.operation_source + local userIdx = ib.data.elements.user_index + -- TODO: This handler can check fabric index and exclude other fabric events + + if opType == nil or opSource == nil then + return + end + + local Type = DoorLock.types.LockOperationTypeEnum + local Lock = capabilities.lock.lock + if opType.value == Type.LOCK then + opType = Lock.locked + elseif opType.value == Type.UNLOCK then + opType = Lock.unlocked + elseif opType.value == Type.UNLATCH then + opType = Lock.unlatched else - device.log.info( - string.format( - "Not handling LockUserChange event because the data type (%s) doesn't affect lock codes", - elements.lock_data_type - ) - ) + return end -end -local function handle_refresh(driver, device, command) - -- Note: no endpoint specified indicates a wildcard endpoint - local req = DoorLock.attributes.LockState:read(device) - req:merge(PowerSource.attributes.BatPercentRemaining:read(device)) - device:send(req) + local Source = DoorLock.types.OperationSourceEnum + if opSource.value == Source.UNSPECIFIED then + opSource = nil + elseif opSource.value == Source.MANUAL then + opSource = "manual" + elseif opSource.value == Source.PROPRIETARY_REMOTE then + opSource = "proprietaryRemote" + elseif opSource.value == Source.KEYPAD then + opSource = "keypad" + elseif opSource.value == Source.AUTO then + opSource = "auto" + elseif opSource.value == Source.BUTTON then + opSource = "button" + elseif opSource.value == Source.SCHEDULE then + opSource = nil + elseif opSource.value == Source.REMOTE then + opSource = "command" + elseif opSource.value == Source.RFID then + opSource = "rfid" + elseif opSource.value == Source.BIOMETRIC then + opSource = nil -- It will be updated R2 + elseif opSource.value == Source.ALIRO then + opSource = "digitalKey" + else + opSource = nil + end + + if userIdx ~= nil then + userIdx = userIdx.value + end + + local data_obj = {method = opSource, userIndex = userIdx} + device:emit_event(opType({data = data_obj, state_change = true})) end -local function handle_lock(driver, device, command) - local ep = device:component_to_endpoint(command.component) - local cota_cred = device:get_field(lock_utils.COTA_CRED) - if cota_cred then - device:send( - DoorLock.server.commands.LockDoor(device, ep, cota_cred) - ) +local function handle_refresh(driver, device, command) + if device:get_field(lock_utils.LOCK_CODES_COPY_REQUIRED) == true then + -- ensure battery state remains as it was before the profile migration + if device:supports_capability(capabilities.battery) then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_PERCENTAGE, {persist = true}) + elseif device:supports_capability(capabilities.batteryLevel) then + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_LEVEL, {persist = true}) + else + device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, {persist = true}) + end + match_profile(driver, device, true) else - device:send(DoorLock.server.commands.LockDoor(device, ep)) + device:send(DoorLock.attributes.LockState:read(device)) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end end -local function handle_unlock(driver, device, command) +local function handle_set_reader_config(driver, device, command) + local cmdName = "setReaderConfig" + local signingKey = command.args.signingKey + local verificationKey = command.args.verificationKey + local groupId = command.args.groupId + local groupResolvingKey = nil + local aliro_ble_uwb_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.ALIROBLEUWB}) + if #aliro_ble_uwb_eps > 0 then + groupResolvingKey = command.args.groupResolvingKey + end + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + local reader_config_updated = device:get_field(lock_utils.ALIRO_READER_CONFIG_UPDATED) or nil + if reader_config_updated == "IN_PROGRESS" then + return + elseif reader_config_updated == "TRUE" then + -- Update commandResult + local command_result_info = { + commandName = cmdName, + statusCode = "success" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.VERIFICATION_KEY, verificationKey, {persist = true}) + device:set_field(lock_utils.GROUP_ID, groupId, {persist = true}) + device:set_field(lock_utils.GROUP_RESOLVING_KEY, groupResolvingKey, {persist = true}) + + -- Send command local ep = device:component_to_endpoint(command.component) - local cota_cred = device:get_field(lock_utils.COTA_CRED) - if cota_cred then - device:send( - DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) + device:send( + DoorLock.server.commands.SetAliroReaderConfig( + device, ep, + lock_utils.hex_string_to_octet_string(signingKey), + lock_utils.hex_string_to_octet_string(verificationKey), + lock_utils.hex_string_to_octet_string(groupId), + lock_utils.hex_string_to_octet_string(groupResolvingKey) ) + ) + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "IN_PROGRESS", {persist = true}) +end + +local function set_aliro_reader_config_handler(driver, device, ib, response) + -- Get result + local cmdName = device:get_field(lock_utils.COMMAND_NAME) + local status = "failure" + if ib.status == DoorLock.types.DlStatus.SUCCESS then + status = "success" + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, "TRUE", {persist = true}) else - device:send(DoorLock.server.commands.UnlockDoor(device, ep)) + if ib.status == DoorLock.types.DlStatus.INVALID_FIELD then + status = "invalidCommand" + end + device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, nil, {persist = true}) end -end -local function handle_delete_code(driver, device, command) - local endpoint = device:component_to_endpoint(command.component) - device:set_field(lock_utils.DELETING_CODE, command.args.codeSlot) - device:send(DoorLock.server.commands.ClearCredential( - device, - endpoint, - {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = command.args.codeSlot} + -- Update commandResult + local command_result_info = { + commandName = cmdName, + statusCode = status + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} )) + device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) end -local function handle_reload_all_codes(driver, device, command) - if (device:get_field(lock_utils.CHECKING_CREDENTIAL) == nil) then - lock_utils.lock_codes_event(device, {}) - device:set_field(lock_utils.CHECKING_CREDENTIAL, 1) - else - device.log.info(string.format("Delaying scanning since currently checking credential %d", device:get_field(lock_utils.CHECKING_CREDENTIAL))) - device.thread:call_with_delay(2, function(t) handle_reload_all_codes(driver, device, command) end) - return +local function handle_set_card_id(driver, device, command) + if command.args.cardId ~= nil then + device:emit_event(capabilities.lockAliro.cardId(command.args.cardId, {visibility = {displayed = false}})) end - device.thread:call_with_delay(5, function(t) - device:emit_event(capabilities.lockCodes.scanCodes("Scanning")) - device:send( - clusters.DoorLock.server.commands.GetCredentialStatus( - device, device:component_to_endpoint(command.component), - {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = device:get_field(lock_utils.CHECKING_CREDENTIAL)} - ) - ) - end) end -local function handle_request_code(driver, device, command) - local endpoint_id = device:component_to_endpoint(command.component) - device:set_field(lock_utils.CHECKING_CREDENTIAL, command.args.codeSlot) +local function handle_set_issuer_key(driver, device, command) + -- Get parameters + local cmdName = "setIssuerKey" + local userIdx = command.args.userIndex + local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + local issuerKey = command.args.issuerKey + local reqId = command.args.requestId local credential = { - credential_type = DoorLock.types.DlCredentialType.PIN, - credential_index = command.args.codeSlot, + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = INITIAL_CREDENTIAL_INDEX } - device:send(clusters.DoorLock.server.commands.GetCredentialStatus(device, endpoint_id, credential)) -end -local function handle_set_code(driver, device, command) - local endpoint = device:component_to_endpoint(command.component) - if (command.args.codePIN == "") then - driver:inject_capability_command( - device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {command.args.codeSlot, command.args.codeName}, - } - ) + -- Adjustment + if userIdx == 0 then + userIdx = nil else - local credential = { - credential_type = DoorLock.types.DlCredentialType.PIN, - credential_index = command.args.codeSlot, + userType = nil + end + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = "busy" } - device:set_field(lock_utils.SET_CREDENTIAL, command.args.codeSlot) - device:send( - DoorLock.server.commands.SetCredential( - device, endpoint, DoorLock.types.DlDataOperationType.ADD, -- operation_type - credential, command.args.codePIN, -- credential_data - nil, -- nil user_index creates a new user - DoorLock.types.DlUserStatus.OCCUPIED_ENABLED, DoorLock.types.DlUserType.UNRESTRICTED_USER - ) - ) - if (command.args.codeName ~= nil) then - -- wait for confirmation from the lock to commit this to memory - -- Groovy driver has a lot more info passed here as a description string, may need to be investigated - local codeState = device:get_field(lock_utils.CODE_STATE) or {} - codeState["setName" .. command.args.codeSlot] = command.args.codeName - device:set_field(lock_utils.CODE_STATE, codeState, {persist = true}) - end + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return end -end -local function handle_name_slot(driver, device, command) - local code_slot = tostring(command.args.codeSlot) - local lock_codes = lock_utils.get_lock_codes(device) - if (lock_codes[code_slot] ~= nil) then - lock_codes[code_slot] = command.args.codeName - device:emit_event( - capabilities.lockCodes.codeChanged( - code_slot .. " renamed", {state_change = true} - ) + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.ISSUER_KEY, issuerKey, {persist = true}) + device:set_field(lock_utils.ISSUER_KEY_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) + device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + lock_utils.hex_string_to_octet_string(issuerKey), -- Credential Data + userIdx, -- User Index + nil, -- User Status + userType -- User Type ) - lock_utils.lock_codes_event(device, lock_codes) - end + ) end -local function find_default_endpoint(device, cluster) - local res = device.MATTER_DEFAULT_ENDPOINT - local eps = device:get_endpoints(cluster) - table.sort(eps) - for _, v in ipairs(eps) do - if v ~= 0 then --0 is the matter RootNode endpoint - return v +local function handle_clear_issuer_key(driver, device, command) + -- Get parameters + local cmdName = "clearIssuerKey" + local userIdx = command.args.userIndex + local reqId = command.args.requestId + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + requestId = reqId, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) + + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Find issuer key index + for index, entry in pairs(aliro_table) do + if entry.userIndex == userIdx and entry.keyType == "issuerKey" then + -- Set parameters + local credential = { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = entry.keyIndex, + } + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) + break end end - device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) - return res end -local function component_to_endpoint(device, component_name) - return find_default_endpoint(device, clusters.DoorLock.ID) -end +local function handle_set_endpoint_key(driver, device, command) + -- Get parameters + local cmdName = "setEndpointKey" + local userIdx = command.args.userIndex + local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER + local keyId = command.args.keyId + local keyType = command.args.keyType + local endpointKey = command.args.endpointKey + local reqId = command.args.requestId + local dataOpType = DoorLock.types.DataOperationTypeEnum.ADD -- Data Operation Type: Add(0), Modify(2) + local endpointKeyIndex = INITIAL_CREDENTIAL_INDEX -local function info_changed(driver, device, event, args) - if device.profile.id ~= args.old_st_store.profile.id then - device:subscribe() + -- Min user index of commandResult is 1 + -- 0 should convert to nil before busy check + if userIdx == 0 then + userIdx = nil end -end -local function do_configure(driver, device) - -- check if the device is NOT currently profiled as base-lock - -- by ANDing a query for every capability in the base-lock profiles. - -- If it does not use base-lock, it is WWST and does not need re-profiling. - if not (device:supports_capability(capabilities.lock) and - device:supports_capability(capabilities.lockCodes) and - device:supports_capability(capabilities.tamperAlert) and - device:supports_capability(capabilities.battery)) then - return - end - - -- if not fingerprinted, dynamically configure base-lock profile - local profile_name = "base-lock" - device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) - local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) - if #battery_feature_eps == 0 then - profile_name = profile_name .. "-nobattery" - device.log.info(string.format("Updating device profile to %s.", profile_name)) - device:try_update_metadata({profile = profile_name}) - else - local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) - req:merge(clusters.PowerSource.attributes.AttributeList:read()) - device:send(req) + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return end -end -local function device_init(driver, device) - device:set_component_to_endpoint_fn(component_to_endpoint) - device:subscribe() + -- Adjustment + if userIdx ~= nil then + userType = nil - -- check if we have a missing COTA credential. Only run this if it has not been run before (i.e. in device added), - -- because there is a delay built into the COTA process and we do not want to start two COTA generations at the same time - -- in the event this was triggered on add. - if not device:get_field(lock_utils.COTA_READ_INITIALIZED) or not device:get_field(lock_utils.COTA_CRED) then - local eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.CREDENTIALSOTA | DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) - if #eps == 0 then - device.log.debug("Device will not require PIN for remote operation") - device:set_field(lock_utils.COTA_CRED, false, {persist = true}) - else - device:send(DoorLock.attributes.RequirePINforRemoteOperation:read(device, eps[1])) - device:set_field(lock_utils.COTA_READ_INITIALIZED, true, {persist = true}) + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + -- Find existing endpoint key + for index, entry in pairs(aliro_table) do + if (entry.keyType == "evictableEndpointKey" or entry.keyType == "nonEvictableEndpointKey") and entry.keyId == keyId then + dataOpType = DoorLock.types.DataOperationTypeEnum.MODIFY + endpointKeyIndex = entry.keyIndex + delete_aliro_from_table(device, userIdx, keyType, keyId) + break + end end end + + local credential = { + credential_type = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType], + credential_index = endpointKeyIndex + } + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.DEVICE_KEY_ID, keyId, {persist = true}) + device:set_field(lock_utils.ENDPOINT_KEY_TYPE, keyType, {persist = true}) + device:set_field(lock_utils.ENDPOINT_KEY, endpointKey, {persist = true}) + device:set_field(lock_utils.ENDPOINT_KEY_INDEX, endpointKeyIndex, {persist = true}) + device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) + + -- Send command + local ep = device:component_to_endpoint(command.component) + device:send( + DoorLock.server.commands.SetCredential( + device, ep, + dataOpType, -- Data Operation Type: Add(0), Modify(2) + credential, -- Credential + lock_utils.hex_string_to_octet_string(endpointKey), -- Credential Data + userIdx, -- User Index + nil, -- User Status + userType -- User Type + ) + ) end -local function device_added(driver, device) - --Note: May want to write OperatingMode to NORMAL, to attempt to ensure remote operation works - --Note: May want to write RequirePINForRemoteOperation, to avoid cota cases if possible. - device:emit_event(capabilities.tamperAlert.tamper.clear()) - local eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) - if #eps == 0 then - if device:supports_capability_by_id(capabilities.tamperAlert.ID) then - device.log.debug("Device does not support lockCodes. Switching profile.") - local profile_name = "lock-without-codes" - device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) - local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) - if #battery_feature_eps == 0 then - profile_name = profile_name .. "-nobattery" - end - device.log.info(string.format("Updating device profile to %s.", profile_name)) - device:try_update_metadata({profile = profile_name}) - else - device.log.debug("Device supports neither lock codes nor tamper. Unable to switch profile.") - end +local function handle_clear_endpoint_key(driver, device, command) + -- Get parameters + local cmdName = "clearEndpointKey" + local userIdx = command.args.userIndex + local keyId = command.args.keyId + local keyType = command.args.keyType + local reqId = command.args.requestId + + -- Check busy state + if lock_utils.is_busy_state_set(device) then + local command_result_info = { + commandName = cmdName, + userIndex = userIdx, + keyId = keyId, + requestId = reqId, + statusCode = "busy" + } + device:emit_event(capabilities.lockAliro.commandResult( + command_result_info, {state_change = true, visibility = {displayed = false}} + )) + return + end + + -- Save values to field + device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) + device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) + device:set_field(lock_utils.DEVICE_KEY_ID, keyId, {persist = true}) + device:set_field(lock_utils.ENDPOINT_KEY_TYPE, keyType, {persist = true}) + device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) + + -- Get latest aliro table + local aliro_table = utils.deep_copy(device:get_latest_state( + "main", + capabilities.lockAliro.ID, + capabilities.lockAliro.credentials.NAME, + {} + )) + + local ep = device:component_to_endpoint(command.component) + if keyId == nil then + return else - local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) - req:merge(DoorLock.attributes.MaxPINCodeLength:read(device, eps[1])) - req:merge(DoorLock.attributes.MinPINCodeLength:read(device, eps[1])) - req:merge(DoorLock.attributes.NumberOfPINUsersSupported:read(device, eps[1])) - driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - - --Device may require pin for remote operation if it supports COTA and PIN features. - eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.CREDENTIALSOTA | DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) - if #eps == 0 then - device.log.debug("Device will not require PIN for remote operation") - device:set_field(lock_utils.COTA_CRED, false, {persist = true}) - else - req:merge(DoorLock.attributes.RequirePINforRemoteOperation:read(device, eps[1])) - device:set_field(lock_utils.COTA_READ_INITIALIZED, true, {persist = true}) + -- Find aliro credential + for index, entry in pairs(aliro_table) do + if entry.userIndex == userIdx and entry.keyId == keyId and entry.keyType == keyType then + -- Set parameters + local credential = { + credential_type = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType], + credential_index = entry.keyIndex, + } + -- Send command + device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) + break + end end - device:send(req) end end local matter_lock_driver = { + NAME = "Matter Lock Driver", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + infoChanged = info_changed, + driverSwitched = driver_switched + }, + shared_device_thread_enabled = true, matter_handlers = { attr = { [DoorLock.ID] = { [DoorLock.attributes.LockState.ID] = lock_state_handler, - [DoorLock.attributes.MaxPINCodeLength.ID] = max_pin_code_len_handler, + [DoorLock.attributes.OperatingMode.ID] = operating_modes_handler, + [DoorLock.attributes.NumberOfTotalUsersSupported.ID] = total_users_supported_handler, + [DoorLock.attributes.NumberOfPINUsersSupported.ID] = pin_users_supported_handler, [DoorLock.attributes.MinPINCodeLength.ID] = min_pin_code_len_handler, - [DoorLock.attributes.NumberOfPINUsersSupported.ID] = num_pin_users_handler, + [DoorLock.attributes.MaxPINCodeLength.ID] = max_pin_code_len_handler, [DoorLock.attributes.RequirePINforRemoteOperation.ID] = require_remote_pin_handler, + [DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser.ID] = max_week_schedule_of_user_handler, + [DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser.ID] = max_year_schedule_of_user_handler, + [DoorLock.attributes.AliroReaderVerificationKey.ID] = aliro_reader_verification_key_handler, + [DoorLock.attributes.AliroReaderGroupIdentifier.ID] = aliro_reader_group_id_handler, + [DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions.ID] = aliro_protocol_versions_handler, + [DoorLock.attributes.AliroGroupResolvingKey.ID] = aliro_group_resolving_key_handler, + [DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions.ID] = aliro_supported_ble_uwb_protocol_versions_handler, + [DoorLock.attributes.AliroBLEAdvertisingVersion.ID] = aliro_ble_advertising_version_handler, + [DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported.ID] = max_aliro_credential_issuer_key_handler, + [DoorLock.attributes.NumberOfAliroEndpointKeysSupported.ID] = max_aliro_endpoint_key_handler, + [DoorLockFeatureMapAttr.ID] = door_lock_feature_map_handler, }, [PowerSource.ID] = { [PowerSource.attributes.AttributeList.ID] = handle_power_source_attribute_list, [PowerSource.attributes.BatPercentRemaining.ID] = handle_battery_percent_remaining, [PowerSource.attributes.BatChargeLevel.ID] = handle_battery_charge_level, - }, + } }, event = { [DoorLock.ID] = { [DoorLock.events.DoorLockAlarm.ID] = alarm_event_handler, [DoorLock.events.LockOperation.ID] = lock_op_event_handler, - [DoorLock.events.LockUserChange.ID] = lock_user_change_event_handler, }, }, cmd_response = { [DoorLock.ID] = { + [DoorLock.server.commands.SetUser.ID] = set_user_response_handler, + [DoorLock.client.commands.GetUserResponse.ID] = get_user_response_handler, + [DoorLock.server.commands.ClearUser.ID] = clear_user_response_handler, [DoorLock.client.commands.SetCredentialResponse.ID] = set_credential_response_handler, - [DoorLock.client.commands.GetCredentialStatusResponse.ID] = get_credential_status_response_handler, [DoorLock.server.commands.ClearCredential.ID] = clear_credential_response_handler, + [DoorLock.server.commands.SetWeekDaySchedule.ID] = set_week_day_schedule_handler, + [DoorLock.server.commands.ClearWeekDaySchedule.ID] = clear_week_day_schedule_handler, + [DoorLock.server.commands.SetYearDaySchedule.ID] = set_year_day_schedule_handler, + [DoorLock.server.commands.SetAliroReaderConfig.ID] = set_aliro_reader_config_handler, }, }, }, subscribed_attributes = subscribed_attributes, - subscribed_events = { - [capabilities.tamperAlert.ID] = {DoorLock.events.DoorLockAlarm, DoorLock.events.LockOperation}, - [capabilities.lockAlarm.ID] = {DoorLock.events.DoorLockAlarm}, - [capabilities.lockCodes.ID] = {DoorLock.events.LockUserChange}, - }, + subscribed_events = subscribed_events, capability_handlers = { - [capabilities.refresh.ID] = {[capabilities.refresh.commands.refresh.NAME] = handle_refresh}, [capabilities.lock.ID] = { [capabilities.lock.commands.lock.NAME] = handle_lock, [capabilities.lock.commands.unlock.NAME] = handle_unlock, + [capabilities.lock.commands.unlatch.NAME] = handle_unlatch + }, + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = handle_add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = handle_update_user, + [capabilities.lockUsers.commands.deleteUser.NAME] = handle_delete_user, + [capabilities.lockUsers.commands.deleteAllUsers.NAME] = handle_delete_all_users, + }, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = handle_add_credential, + [capabilities.lockCredentials.commands.updateCredential.NAME] = handle_update_credential, + [capabilities.lockCredentials.commands.deleteCredential.NAME] = handle_delete_credential, + [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = handle_delete_all_credentials, }, - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.deleteCode.NAME] = handle_delete_code, - [capabilities.lockCodes.commands.reloadAllCodes.NAME] = handle_reload_all_codes, - [capabilities.lockCodes.commands.requestCode.NAME] = handle_request_code, - [capabilities.lockCodes.commands.setCode.NAME] = handle_set_code, - [capabilities.lockCodes.commands.nameSlot.NAME] = handle_name_slot, + [capabilities.lockSchedules.ID] = { + [capabilities.lockSchedules.commands.setWeekDaySchedule.NAME] = handle_set_week_day_schedule, + [capabilities.lockSchedules.commands.clearWeekDaySchedules.NAME] = handle_clear_week_day_schedule, + [capabilities.lockSchedules.commands.setYearDaySchedule.NAME] = handle_set_year_day_schedule, + [capabilities.lockSchedules.commands.clearYearDaySchedules.NAME] = handle_clear_year_day_schedule, }, + [capabilities.lockAliro.ID] = { + [capabilities.lockAliro.commands.setReaderConfig.NAME] = handle_set_reader_config, + [capabilities.lockAliro.commands.setCardId.NAME] = handle_set_card_id, + [capabilities.lockAliro.commands.setIssuerKey.NAME] = handle_set_issuer_key, + [capabilities.lockAliro.commands.clearIssuerKey.NAME] = handle_clear_issuer_key, + [capabilities.lockAliro.commands.setEndpointKey.NAME] = handle_set_endpoint_key, + [capabilities.lockAliro.commands.clearEndpointKey.NAME] = handle_clear_endpoint_key, + }, + [capabilities.refresh.ID] = {[capabilities.refresh.commands.refresh.NAME] = handle_refresh} }, supported_capabilities = { capabilities.lock, - capabilities.lockCodes, - capabilities.tamperAlert, + capabilities.lockUsers, + capabilities.lockCredentials, + capabilities.lockSchedules, capabilities.battery, - capabilities.batteryLevel, - }, - sub_drivers = { - require("new-matter-lock"), - }, - lifecycle_handlers = { - init = device_init, - added = device_added, - doConfigure = do_configure, - infoChanged = info_changed, + capabilities.batteryLevel }, + sub_drivers = require("sub_drivers"), } ------------------------------------------------------------------------------------------------------------------------------ --- Driver Initialization ------------------------------------------------------------------------------------------------------------------------------ local matter_driver = MatterDriver("matter-lock", matter_lock_driver) matter_driver:run() diff --git a/drivers/SmartThings/matter-lock/src/lazy_load_subdriver.lua b/drivers/SmartThings/matter-lock/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..a04740d267 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/lazy_load_subdriver.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + local MatterDriver = require "st.matter.driver" + local version = require "version" + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return MatterDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/matter-lock/src/lock_utils.lua b/drivers/SmartThings/matter-lock/src/lock_utils.lua index 816ca446f2..3836669115 100644 --- a/drivers/SmartThings/matter-lock/src/lock_utils.lua +++ b/drivers/SmartThings/matter-lock/src/lock_utils.lua @@ -1,16 +1,8 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local security = require "st.security" +local PUB_KEY_PREFIX = "04" local lock_utils = { -- Lock device field names @@ -50,12 +42,21 @@ local lock_utils = { ENDPOINT_KEY_INDEX = "endpointKeyIndex", ENDPOINT_KEY_TYPE = "endpointKeyType", DEVICE_KEY_ID = "deviceKeyId", - COMMAND_REQUEST_ID = "commandRequestId" + COMMAND_REQUEST_ID = "commandRequestId", + MODULAR_PROFILE_UPDATED = "__MODULAR_PROFILE_UPDATED", + ALIRO_READER_CONFIG_UPDATED = "aliroReaderConfigUpdated", + LATEST_DOOR_LOCK_FEATURE_MAP = "latestDoorLockFeatureMap", + LOCK_CODES_COPY_REQUIRED = "lockCodesCopyRequired", + LOCK_CODES_FOR_MIGRATION = "lockCodesForMigration", } local capabilities = require "st.capabilities" local json = require "st.json" local utils = require "st.utils" +lock_utils.MIN_EPOCH_S = 0 +lock_utils.MAX_EPOCH_S = 0xffffffff +lock_utils.THIRTY_YEARS_S = 946684800 -- 1970-01-01T00:00:00 ~ 2000-01-01T00:00:00 + lock_utils.get_lock_codes = function(device) local lc = device:get_field(lock_utils.LOCK_CODES) return lc ~= nil and lc or {} @@ -113,4 +114,199 @@ end -- keys are the code slots that ST uses -- user_index and credential_index are used in the matter commands -- +function lock_utils.get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +function lock_utils.set_field_for_endpoint(device, field, endpoint, value, additional_params) + device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) +end + +function lock_utils.optional_capabilities_list_changed(new_component_capability_list, previous_component_capability_list) + local previous_capability_map = {} + local component_sizes = {} + local previous_component_count = 0 + for component_name, component in pairs(previous_component_capability_list or {}) do + previous_capability_map[component_name] = {} + component_sizes[component_name] = 0 + for _, capability in pairs(component.capabilities or {}) do + if capability.id ~= "lock" and capability.id ~= "lockAlarm" and capability.id ~= "remoteControlStatus" and + capability.id ~= "firmwareUpdate" and capability.id ~= "refresh" then + previous_capability_map[component_name][capability.id] = true + component_sizes[component_name] = component_sizes[component_name] + 1 + end + end + previous_component_count = previous_component_count + 1 + end + + local number_of_components_counted = 0 + for _, new_component_capabilities in pairs(new_component_capability_list or {}) do + local component_name = new_component_capabilities[1] + local capability_list = new_component_capabilities[2] + number_of_components_counted = number_of_components_counted + 1 + if previous_capability_map[component_name] == nil then + return true + end + for _, capability in ipairs(capability_list) do + if previous_capability_map[component_name][capability] == nil then + return true + end + end + if #capability_list ~= component_sizes[component_name] then + return true + end + end + + if number_of_components_counted ~= previous_component_count then + return true + end + + return false +end + +-- This function check busy_state and if busy_state is false, set it to true(current time) +function lock_utils.is_busy_state_set(device) + local c_time = os.time() + local busy_state = device:get_field(lock_utils.BUSY_STATE) or false + if busy_state == false or c_time - busy_state > 10 then + device:set_field(lock_utils.BUSY_STATE, c_time, {persist = true}) + return false + else + return true + end +end + +-- This type represents an offset, in seconds, from 0 hours, 0 minutes, 0 seconds, on the 1st of January, 2000 UTC +function lock_utils.iso8601_to_epoch(iso_str) + local pattern = "^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)" + local year, month, day, hour, min, sec = iso_str:match(pattern) + if not year then + return nil + end + local epoch_s = os.time({ + year = tonumber(year), + month = tonumber(month), + day = tonumber(day), + hour = tonumber(hour), + min = tonumber(min), + sec = tonumber(sec), + }) + + -- The os.time() is based on 1970. Thirty years must be subtracted for calculations from 2000. + epoch_s = epoch_s - lock_utils.THIRTY_YEARS_S + + if epoch_s < lock_utils.MIN_EPOCH_S then + return lock_utils.MIN_EPOCH_S + elseif epoch_s > lock_utils.MAX_EPOCH_S then + return lock_utils.MAX_EPOCH_S + else + return epoch_s + end +end + +function lock_utils.hex_string_to_octet_string(hex_string) + if hex_string == nil then + return nil + end + local octet_string = "" + for i = 1, #hex_string, 2 do + local hex = hex_string:sub(i, i + 1) + octet_string = octet_string .. string.char(tonumber(hex, 16)) + end + return octet_string +end + +function lock_utils.create_group_id_resolving_key() + math.randomseed(os.time()) + local result = string.format("%02x", math.random(0, 255)) + for i = 1, 15 do + result = result .. string.format("%02x", math.random(0, 255)) + end + return result +end + +function lock_utils.generate_keypair(device) + local request_opts = { + key_algorithm = { + type = "ec", + curve = "prime256v1" + }, + signature_algorithm = "sha256", + return_formats = { + pem = true, + der = true + }, + subject = { + common_name = "reader config" + }, + validity_days = 36500, + x509_extensions = { + key_usage = { + critical = true, + digital_signature = true + }, + certificate_policies = { + critical = true, + policy_2030_5_self_signed_client = true + } + } + } + local status = security.generate_self_signed_cert(request_opts) + if not status or not status.key_der then + device.log.error("generate_self_signed_cert returned no data") + return nil, nil + end + + local der = status.key_der + local privKey, pubKey = nil, nil + -- Helper: Parse ASN.1 length (handles 1-byte and multi-byte lengths) + local function get_length(data, start_pos) + local b = string.byte(data, start_pos) + if not b then return nil, start_pos end + + if b < 0x80 then + return b, start_pos + 1 + else + local num_bytes = b - 0x80 + local len = 0 + for i = 1, num_bytes do + len = (len * 256) + string.byte(data, start_pos + i) + end + return len, start_pos + 1 + num_bytes + end + end + -- Start parsing after the initial SEQUENCE tag (0x30) + -- Most keys start: [0x30][Length]. We find the first length to find the start of content. + local _, pos = get_length(der, 2) + + while pos < #der do + local tag = string.byte(der, pos) + local len, content_start = get_length(der, pos + 1) + if not len then break end + if tag == 0x04 then + -- PRIVATE KEY: Octet String + privKey = utils.bytes_to_hex_string(string.sub(der, content_start, content_start + len - 1)) + elseif tag == 0xA1 then + -- PUBLIC KEY Wrapper: Explicit Tag [1] + -- Inside 0xA1 is a BIT STRING (0x03) + local inner_tag = string.byte(der, content_start) + if inner_tag == 0x03 then + local bit_len, bit_start = get_length(der, content_start + 1) + -- BIT STRINGS have a "leading null byte" (unused bits indicator) + -- We skip that byte (bit_start) and the 0x04 EC prefix to get the raw X/Y coordinates + local actual_key_start = bit_start + 2 + local actual_key_len = bit_len - 2 + pubKey = PUB_KEY_PREFIX .. utils.bytes_to_hex_string(string.sub(der, actual_key_start, actual_key_start + actual_key_len - 1)) + end + end + -- Move pointer to the next tag + pos = content_start + len + end + + if not privKey or not pubKey then + device.log.error("Failed to extract keys from DER") + end + return privKey, pubKey +end + return lock_utils diff --git a/drivers/SmartThings/matter-lock/src/new-matter-lock/init.lua b/drivers/SmartThings/matter-lock/src/new-matter-lock/init.lua deleted file mode 100644 index 1d59b1ab14..0000000000 --- a/drivers/SmartThings/matter-lock/src/new-matter-lock/init.lua +++ /dev/null @@ -1,2973 +0,0 @@ --- Copyright 2024 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. - -local device_lib = require "st.device" -local capabilities = require "st.capabilities" -local clusters = require "st.matter.clusters" -local im = require "st.matter.interaction_model" -local utils = require "st.utils" -local lock_utils = require "lock_utils" - -local version = require "version" -if version.api < 10 then - clusters.DoorLock = require "DoorLock" -end - -local DoorLock = clusters.DoorLock -local PowerSource = clusters.PowerSource - -local INITIAL_CREDENTIAL_INDEX = 1 -local ALL_INDEX = 0xFFFE -local MIN_EPOCH_S = 0 -local MAX_EPOCH_S = 0xffffffff -local THIRTY_YEARS_S = 946684800 -- 1970-01-01T00:00:00 ~ 2000-01-01T00:00:00 - -local MODULAR_PROFILE_UPDATED = "__MODULAR_PROFILE_UPDATED" - -local RESPONSE_STATUS_MAP = { - [DoorLock.types.DlStatus.SUCCESS] = "success", - [DoorLock.types.DlStatus.FAILURE] = "failure", - [DoorLock.types.DlStatus.DUPLICATE] = "duplicate", - [DoorLock.types.DlStatus.OCCUPIED] = "occupied", - [DoorLock.types.DlStatus.INVALID_FIELD] = "invalidCommand", - [DoorLock.types.DlStatus.RESOURCE_EXHAUSTED] = "resourceExhausted", - [DoorLock.types.DlStatus.NOT_FOUND] = "failure" -} - -local WEEK_DAY_MAP = { - ["Sunday"] = 1, - ["Monday"] = 2, - ["Tuesday"] = 4, - ["Wednesday"] = 8, - ["Thursday"] = 16, - ["Friday"] = 32, - ["Saturday"] = 64, -} - -local ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP = { - ["evictableEndpointKey"] = DoorLock.types.CredentialTypeEnum.ALIRO_EVICTABLE_ENDPOINT_KEY, - ["nonEvictableEndpointKey"] = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY -} - -local NEW_MATTER_LOCK_PRODUCTS = { - {0x115f, 0x2802}, -- AQARA, U200 - {0x115f, 0x2801}, -- AQARA, U300 - {0x115f, 0x2807}, -- AQARA, U200 Lite - {0x147F, 0x0001}, -- U-tec - {0x147F, 0x0008}, -- Ultraloq, Bolt Smart Matter Door Lock - {0x144F, 0x4002}, -- Yale, Linus Smart Lock L2 - {0x101D, 0x8110}, -- Yale, New Lock - {0x1533, 0x0001}, -- eufy, E31 - {0x1533, 0x0002}, -- eufy, E30 - {0x1533, 0x0003}, -- eufy, C34 - {0x1533, 0x000F}, -- eufy, FamiLock S3 Max - {0x1533, 0x0010}, -- eufy, FamiLock S3 - {0x1533, 0x0011}, -- eufy, FamiLock E34 - {0x1533, 0x0012}, -- eufy, FamiLock E35 - {0x1533, 0x0016}, -- eufy, FamiLock E32 - {0x135D, 0x00B1}, -- Nuki, Smart Lock Pro - {0x135D, 0x00B2}, -- Nuki, Smart Lock - {0x135D, 0x00C1}, -- Nuki, Smart Lock - {0x135D, 0x00A1}, -- Nuki, Smart Lock - {0x135D, 0x00B0}, -- Nuki, Smart Lock - {0x15F2, 0x0001}, -- Viomi, AiSafety Smart Lock E100 - {0x158B, 0x0001}, -- Deasino, DS-MT01 - {0x10E1, 0x2002}, -- VDA - {0x1421, 0x0042}, -- Kwikset Halo Select Plus -} - -local battery_support = { - NO_BATTERY = "NO_BATTERY", - BATTERY_LEVEL = "BATTERY_LEVEL", - BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" -} - -local profiling_data = { - BATTERY_SUPPORT = "__BATTERY_SUPPORT", -} - -local subscribed_attributes = { - [capabilities.lock.ID] = { - DoorLock.attributes.LockState - }, - [capabilities.remoteControlStatus.ID] = { - DoorLock.attributes.OperatingMode - }, - [capabilities.lockUsers.ID] = { - DoorLock.attributes.NumberOfTotalUsersSupported - }, - [capabilities.lockCredentials.ID] = { - DoorLock.attributes.NumberOfPINUsersSupported, - DoorLock.attributes.MaxPINCodeLength, - DoorLock.attributes.MinPINCodeLength, - DoorLock.attributes.RequirePINforRemoteOperation - }, - [capabilities.lockSchedules.ID] = { - DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser, - DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser - }, - [capabilities.lockAliro.ID] = { - DoorLock.attributes.AliroReaderVerificationKey, - DoorLock.attributes.AliroReaderGroupIdentifier, - DoorLock.attributes.AliroReaderGroupSubIdentifier, - DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions, - DoorLock.attributes.AliroGroupResolvingKey, - DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions, - DoorLock.attributes.AliroBLEAdvertisingVersion, - DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported, - DoorLock.attributes.NumberOfAliroEndpointKeysSupported, - }, - [capabilities.battery.ID] = { - PowerSource.attributes.BatPercentRemaining - }, - [capabilities.batteryLevel.ID] = { - PowerSource.attributes.BatChargeLevel - } -} - -local subscribed_events = { - [capabilities.lock.ID] = { - DoorLock.events.LockOperation - }, - [capabilities.lockAlarm.ID] = { - DoorLock.events.DoorLockAlarm - }, - [capabilities.lockUsers.ID] = { - DoorLock.events.LockUserChange - } -} - -local function is_new_matter_lock_products(opts, driver, device) - if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then - return false - end - for _, p in ipairs(NEW_MATTER_LOCK_PRODUCTS) do - if device.manufacturer_info.vendor_id == p[1] and - device.manufacturer_info.product_id == p[2] then - return true - end - end - return false -end - -local function find_default_endpoint(device, cluster) - local res = device.MATTER_DEFAULT_ENDPOINT - local eps = device:get_endpoints(cluster) - table.sort(eps) - for _, v in ipairs(eps) do - if v ~= 0 then --0 is the matter RootNode endpoint - return v - end - end - device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) - return res -end - -local function component_to_endpoint(device, component_name) - return find_default_endpoint(device, clusters.DoorLock.ID) -end - -local function device_init(driver, device) - device:set_component_to_endpoint_fn(component_to_endpoint) - for cap_id, attributes in pairs(subscribed_attributes) do - if device:supports_capability_by_id(cap_id) then - for _, attr in ipairs(attributes) do - device:add_subscribed_attribute(attr) - end - end - end - for cap_id, events in pairs(subscribed_events) do - if device:supports_capability_by_id(cap_id) then - for _, e in ipairs(events) do - device:add_subscribed_event(e) - end - end - end - device:subscribe() - end - -local function device_added(driver, device) - device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true})) - local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) - if #battery_feature_eps > 0 then - device:send(clusters.PowerSource.attributes.AttributeList:read(device)) - else - device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, { persist = true }) - end -end - -local function match_profile_modular(driver, device) - local enabled_optional_component_capability_pairs = {} - local main_component_capabilities = {} - local modular_profile_name = "lock-modular" - for _, device_ep in pairs(device.endpoints) do - for _, ep_cluster in pairs(device_ep.clusters) do - if ep_cluster.cluster_id == DoorLock.ID then - local clus_has_feature = function(feature_bitmap) - return DoorLock.are_features_supported(feature_bitmap, ep_cluster.feature_map) - end - if clus_has_feature(DoorLock.types.Feature.USER) then - table.insert(main_component_capabilities, capabilities.lockUsers.ID) - end - if clus_has_feature(DoorLock.types.Feature.PIN_CREDENTIAL) then - table.insert(main_component_capabilities, capabilities.lockCredentials.ID) - end - if clus_has_feature(DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES) or - clus_has_feature(DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES) then - table.insert(main_component_capabilities, capabilities.lockSchedules.ID) - end - if clus_has_feature(DoorLock.types.Feature.UNBOLT) then - device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) - device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) - modular_profile_name = "lock-modular-embedded-unlatch" -- use the embedded config specified in this profile for devices supporting "unlatch" - else - device:emit_event(capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) - device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) - end - if clus_has_feature(DoorLock.types.Feature.ALIRO_PROVISIONING) then - table.insert(main_component_capabilities, capabilities.lockAliro.ID) - end - break - end - end - end - - local supported_battery_type = device:get_field(profiling_data.BATTERY_SUPPORT) - if supported_battery_type == battery_support.BATTERY_LEVEL then - table.insert(main_component_capabilities, capabilities.batteryLevel.ID) - elseif supported_battery_type == battery_support.BATTERY_PERCENTAGE then - table.insert(main_component_capabilities, capabilities.battery.ID) - end - - table.insert(enabled_optional_component_capability_pairs, {"main", main_component_capabilities}) - device:try_update_metadata({profile = modular_profile_name, optional_component_capabilities = enabled_optional_component_capability_pairs}) - device:set_field(MODULAR_PROFILE_UPDATED, true) -end - -local function match_profile_switch(driver, device) - local user_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.USER}) - local pin_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.PIN_CREDENTIAL}) - local week_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES}) - local year_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES}) - local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) - - local profile_name = "lock" - if #user_eps > 0 then - profile_name = profile_name .. "-user" - if #pin_eps > 0 then - profile_name = profile_name .. "-pin" - end - if #week_schedule_eps + #year_schedule_eps > 0 then - profile_name = profile_name .. "-schedule" - end - end - if #unbolt_eps > 0 then - profile_name = profile_name .. "-unlatch" - device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) - else - device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) - end - - local supported_battery_type = device:get_field(profiling_data.BATTERY_SUPPORT) - if supported_battery_type == battery_support.BATTERY_LEVEL then - profile_name = profile_name .. "-batteryLevel" - elseif supported_battery_type == battery_support.BATTERY_PERCENTAGE then - profile_name = profile_name .. "-battery" - end - - device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) - device:try_update_metadata({profile = profile_name}) -end - -local function info_changed(driver, device, event, args) - if device.profile.id == args.old_st_store.profile.id and not device:get_field(MODULAR_PROFILE_UPDATED) then - return - end - device:set_field(MODULAR_PROFILE_UPDATED, nil) - for cap_id, attributes in pairs(subscribed_attributes) do - if device:supports_capability_by_id(cap_id) then - for _, attr in ipairs(attributes) do - device:add_subscribed_attribute(attr) - end - end - end - for cap_id, events in pairs(subscribed_events) do - if device:supports_capability_by_id(cap_id) then - for _, e in ipairs(events) do - device:add_subscribed_event(e) - end - end - end - device:subscribe() - device:emit_event(capabilities.lockAlarm.alarm.clear({state_change = true})) - device:emit_event(capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) -- lockJammed is madatory -end - -local function profiling_data_still_required(device) - for _, field in pairs(profiling_data) do - if device:get_field(field) == nil then - return true -- data still required if a field is nil - end - end - return false -end - -local function match_profile(driver, device) - if profiling_data_still_required(device) then return end - - if version.api >= 15 and version.rpc >= 9 then - match_profile_modular(driver, device) - else - match_profile_switch(driver, device) - end -end - -local function do_configure(driver, device) - match_profile(driver, device) -end - -local function driver_switched(driver, device) - match_profile(driver, device) -end - --- This function check busy_state and if busy_state is false, set it to true(current time) -local function is_busy_state_set(device) - local c_time = os.time() - local busy_state = device:get_field(lock_utils.BUSY_STATE) or false - if busy_state == false or c_time - busy_state > 10 then - device:set_field(lock_utils.BUSY_STATE, c_time, {persist = true}) - return false - else - return true - end -end - --- Matter Handler ----------------- --- Lock State -- ----------------- -local function lock_state_handler(driver, device, ib, response) - local LockState = DoorLock.attributes.LockState - local attr = capabilities.lock.lock - local LOCK_STATE = { - [LockState.NOT_FULLY_LOCKED] = attr.not_fully_locked(), - [LockState.LOCKED] = attr.locked(), - [LockState.UNLOCKED] = attr.unlocked(), - [LockState.UNLATCHED] = attr.unlatched() - } - - -- The lock state is usually updated in lock_state_handler and lock_op_event_handler, respectively. - -- In this case, two events occur. To prevent this, when both functions are called, - -- it send the event after 1 second so that no event occurs in the lock_state_handler. - device.thread:call_with_delay(1, function () - if ib.data.value ~= nil then - device:emit_event(LOCK_STATE[ib.data.value]) - else - device.log.warn("Lock State is nil") - end - end) -end - ---------------------- --- Operating Modes -- ---------------------- -local function operating_modes_handler(driver, device, ib, response) - local status = capabilities.remoteControlStatus.remoteControlEnabled - local op_type = DoorLock.types.OperatingModeEnum - local opMode_map = { - [op_type.NORMAL] = true, - [op_type.VACATION] = true, - [op_type.PRIVACY] = false, - [op_type.NO_REMOTE_LOCK_UNLOCK] = false, - [op_type.PASSAGE] = false, - } - local result = opMode_map[ib.data.value] - local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) - if result == true then - device:emit_event(status("true", {visibility = {displayed = true}})) - if #unbolt_eps > 0 then - device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) - else - device:emit_event(capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) - end - elseif result == false then - device:emit_event(status("false", {visibility = {displayed = true}})) - device:emit_event(capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) - end -end - -------------------------------------- --- Number Of Total Users Supported -- -------------------------------------- -local function total_users_supported_handler(driver, device, ib, response) - device:emit_event(capabilities.lockUsers.totalUsersSupported(ib.data.value, {visibility = {displayed = false}})) -end - ----------------------------------- --- Number Of PIN Users Supported -- ----------------------------------- -local function pin_users_supported_handler(driver, device, ib, response) - device:emit_event(capabilities.lockCredentials.pinUsersSupported(ib.data.value, {visibility = {displayed = false}})) -end - -------------------------- --- Min PIN Code Length -- -------------------------- -local function min_pin_code_len_handler(driver, device, ib, response) - device:emit_event(capabilities.lockCredentials.minPinCodeLen(ib.data.value, {visibility = {displayed = false}})) -end - -------------------------- --- Max PIN Code Length -- -------------------------- -local function max_pin_code_len_handler(driver, device, ib, response) - device:emit_event(capabilities.lockCredentials.maxPinCodeLen(ib.data.value, {visibility = {displayed = false}})) -end - --------------------------------------- --- Require PIN For Remote Operation -- --------------------------------------- -local function set_cota_credential(device, credential_index) - local eps = device:get_endpoints(DoorLock.ID) - local cota_cred = device:get_field(lock_utils.COTA_CRED) - if cota_cred == nil then - -- Shouldn't happen but defensive to try to figure out if we need the cota cred and set it. - device:send(DoorLock.attributes.RequirePINforRemoteOperation:read(device, #eps > 0 and eps[1] or 1)) - return - elseif cota_cred == false then - device.log.debug("Device does not require PIN for remote operation. Not setting COTA credential") - return - end - - -- Check Busy State - if is_busy_state_set(device) then - device.log.debug("delaying setting COTA credential since a credential is currently being set") - device.thread:call_with_delay(2, function(t) - set_cota_credential(device, credential_index) - end) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, "addCota") - device:set_field(lock_utils.CRED_INDEX, credential_index) - device:set_field(lock_utils.COTA_CRED_INDEX, credential_index, {persist = true}) - device:set_field(lock_utils.USER_TYPE, "remote") - - -- Send command - device.log.info(string.format("Attempting to set COTA credential at index %s", credential_index)) - local credential = { - credential_type = DoorLock.types.CredentialTypeEnum.PIN, - credential_index = credential_index - } - device:send(DoorLock.server.commands.SetCredential( - device, - #eps > 0 and eps[1] or 1, - DoorLock.types.DataOperationTypeEnum.ADD, - credential, - device:get_field(lock_utils.COTA_CRED), - nil, -- nil user_index creates a new user - DoorLock.types.UserStatusEnum.OCCUPIED_ENABLED, - DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER - )) -end - -local function generate_cota_cred_for_device(device) - local len = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) or 6 - local cred_data = math.floor(math.random() * (10 ^ len)) - cred_data = string.format("%0" .. tostring(len) .. "d", cred_data) - device:set_field(lock_utils.COTA_CRED, cred_data, {persist = true}) -end - -local function apply_cota_credentials_if_absent(device) - if not device:get_field(lock_utils.COTA_CRED) then - -- Process after all other info blocks have been dispatched to ensure MaxPINCodeLength has been processed - device.thread:call_with_delay(0, function(t) - generate_cota_cred_for_device(device) - -- delay needed to allow test to override the random credential data - device.thread:call_with_delay(0, function(t) - -- Attempt to set cota credential at the lowest index - set_cota_credential(device, INITIAL_CREDENTIAL_INDEX) - end) - end) - end -end - -local function require_remote_pin_handler(driver, device, ib, response) - if ib.data.value then - apply_cota_credentials_if_absent(device) - else - device:set_field(lock_utils.COTA_CRED, false, {persist = true}) - end -end - ------------------------------------------------------ --- Number Of Week Day Schedules Supported Per User -- ------------------------------------------------------ -local function max_week_schedule_of_user_handler(driver, device, ib, response) - device:emit_event(capabilities.lockSchedules.weekDaySchedulesPerUser(ib.data.value, {visibility = {displayed = false}})) -end - ------------------------------------------------------ --- Number Of Year Day Schedules Supported Per User -- ------------------------------------------------------ -local function max_year_schedule_of_user_handler(driver, device, ib, response) - device:emit_event(capabilities.lockSchedules.yearDaySchedulesPerUser(ib.data.value, {visibility = {displayed = false}})) -end - ----------------- --- Aliro Util -- ----------------- -local function hex_string_to_octet_string(hex_string) - if hex_string == nil then - return nil - end - local octet_string = "" - for i = 1, #hex_string, 2 do - local hex = hex_string:sub(i, i + 1) - octet_string = octet_string .. string.char(tonumber(hex, 16)) - end - return octet_string -end - ------------------------------------ --- Aliro Reader Verification Key -- ------------------------------------ -local function aliro_reader_verification_key_handler(driver, device, ib, response) - if ib.data.value ~= nil then - device:emit_event(capabilities.lockAliro.readerVerificationKey( - utils.bytes_to_hex_string(ib.data.value), {visibility = {displayed = false}} - )) - end -end - ------------------------------------ --- Aliro Reader Group Identifier -- ------------------------------------ -local function aliro_reader_group_id_handler(driver, device, ib, response) - if ib.data.value ~= nil then - device:emit_event(capabilities.lockAliro.readerGroupIdentifier( - utils.bytes_to_hex_string(ib.data.value), - {visibility = {displayed = false}} - )) - end -end - -------------------------------------------------------------- --- Aliro Expedited Transaction Supported Protocol Versions -- -------------------------------------------------------------- -local function aliro_group_resolving_key_handler(driver, device, ib, response) - if ib.data.value ~= nil then - device:emit_event(capabilities.lockAliro.groupResolvingKey( - utils.bytes_to_hex_string(ib.data.value), - {visibility = {displayed = false}} - )) - end -end - -------------------------------- --- Aliro Group Resolving Key -- -------------------------------- -local function aliro_protocol_versions_handler(driver, device, ib, response) - if ib.data.elements == nil then - return - end - local protocol_versions = {} - for i, element in ipairs(ib.data.elements) do - local version = string.format("%s.%s", element.value:byte(1), element.value:byte(2)) - table.insert(protocol_versions, version); - end - device:emit_event(capabilities.lockAliro.expeditedTransactionProtocolVersions(protocol_versions, {visibility = {displayed = false}})) -end - ------------------------------------------------ --- Aliro Supported BLE UWB Protocol Versions -- ------------------------------------------------ -local function aliro_supported_ble_uwb_protocol_versions_handler(driver, device, ib, response) - if ib.data.elements == nil then - return - end - local protocol_versions = {} - for i, element in ipairs(ib.data.elements) do - local version = string.format("%s.%s", element.value:byte(1), element.value:byte(2)) - table.insert(protocol_versions, version); - end - device:emit_event(capabilities.lockAliro.bleUWBProtocolVersions(protocol_versions, {visibility = {displayed = false}})) -end - ------------------------------------ --- Aliro BLE Advertising Version -- ------------------------------------ -local function aliro_ble_advertising_version_handler(driver, device, ib, response) - if ib.data.value ~= nil then - device:emit_event(capabilities.lockAliro.bleAdvertisingVersion(string.format("%s", ib.data.value), {visibility = {displayed = false}})) - end -end - ------------------------------------------------------- --- Number Of Aliro Credential Issuer Keys Supported -- ------------------------------------------------------- -local function max_aliro_credential_issuer_key_handler(driver, device, ib, response) - if ib.data.value ~= nil then - device:emit_event(capabilities.lockAliro.maxCredentialIssuerKeys(ib.data.value, {visibility = {displayed = false}})) - end -end - ---------------------------------------------- --- Number Of Aliro Endpoint Keys Supported -- ---------------------------------------------- -local function max_aliro_endpoint_key_handler(driver, device, ib, response) - if ib.data.value ~= nil then - device:emit_event(capabilities.lockAliro.maxEndpointKeys(ib.data.value, {visibility = {displayed = false}})) - end -end - ---------------------------------- --- Power Source Attribute List -- ---------------------------------- -local function handle_power_source_attribute_list(driver, device, ib, response) - for _, attr in ipairs(ib.data.elements) do - -- mark if the device if BatPercentRemaining (Attribute ID 0x0C) or - -- BatChargeLevel (Attribute ID 0x0E) is present and try profiling. - if attr.value == 0x0C then - device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_PERCENTAGE, { persist = true }) - match_profile(driver, device) - return - elseif attr.value == 0x0E then - device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_LEVEL, { persist = true }) - match_profile(driver, device) - return - end - end - - -- neither BatChargeLevel nor BatPercentRemaining were found. Re-profiling without battery. - device:set_field(profiling_data.BATTERY_SUPPORT, battery_support.NO_BATTERY, { persist = true }) - match_profile(driver, device) -end - -------------------------------- --- Battery Percent Remaining -- -------------------------------- -local function handle_battery_percent_remaining(driver, device, ib, response) - if ib.data.value ~= nil then - device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) - end -end - --------------------------- --- Battery Charge Level -- --------------------------- -local function handle_battery_charge_level(driver, device, ib, response) - if ib.data.value == PowerSource.types.BatChargeLevelEnum.OK then - device:emit_event(capabilities.batteryLevel.battery.normal()) - elseif ib.data.value == PowerSource.types.BatChargeLevelEnum.WARNING then - device:emit_event(capabilities.batteryLevel.battery.warning()) - elseif ib.data.value == PowerSource.types.BatChargeLevelEnum.CRITICAL then - device:emit_event(capabilities.batteryLevel.battery.critical()) - end -end - --- Capability Handler ------------------ --- Lock/Unlock -- ------------------ -local function handle_lock(driver, device, command) - local ep = device:component_to_endpoint(command.component) - local cota_cred = device:get_field(lock_utils.COTA_CRED) - if cota_cred then - device:send( - DoorLock.server.commands.LockDoor(device, ep, cota_cred) - ) - else - device:send(DoorLock.server.commands.LockDoor(device, ep)) - end -end - -local function handle_unlock(driver, device, command) - local unbolt_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.UNBOLT}) - local cota_cred = device:get_field(lock_utils.COTA_CRED) - local ep = device:component_to_endpoint(command.component) - - if #unbolt_eps > 0 then - if cota_cred then - device:send( - DoorLock.server.commands.UnboltDoor(device, ep, cota_cred) - ) - else - device:send(DoorLock.server.commands.UnboltDoor(device, ep)) - end - else - if cota_cred then - device:send( - DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) - ) - else - device:send(DoorLock.server.commands.UnlockDoor(device, ep)) - end - end -end - -local function handle_unlatch(driver, device, command) - local ep = device:component_to_endpoint(command.component) - local cota_cred = device:get_field(lock_utils.COTA_CRED) - if cota_cred then - device:send( - DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) - ) - else - device:send(DoorLock.server.commands.UnlockDoor(device, ep)) - end -end - ----------------- --- User Table -- ----------------- -local function add_user_to_table(device, userIdx, userName, userType) - -- Get latest user table - local user_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockUsers.ID, - capabilities.lockUsers.users.NAME, - {} - )) - - -- Add new entry to table - table.insert(user_table, {userIndex = userIdx, userName = userName, userType = userType}) - device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) -end - -local function update_user_in_table(device, userIdx, userName, userType) - -- Get latest user table - local user_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockUsers.ID, - capabilities.lockUsers.users.NAME, - {} - )) - - -- Find user entry - local i = 0 - for index, entry in pairs(user_table) do - if entry.userIndex == userIdx then - i = index - break - end - end - - -- Update user entry - if i ~= 0 then - user_table[i].userType = userType - user_table[i].userName = userName - device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) - end -end - -local function delete_user_from_table(device, userIdx) - -- If User Index is ALL_INDEX, remove all entry from the table - if userIdx == ALL_INDEX then - device:emit_event(capabilities.lockUsers.users({}, {visibility = {displayed = false}})) - return - end - - -- Get latest user table - local user_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockUsers.ID, - capabilities.lockUsers.users.NAME, - {} - )) - - -- Remove element from user table - for index, entry in pairs(user_table) do - if entry.userIndex == userIdx then - table.remove(user_table, index) - break - end - end - device:emit_event(capabilities.lockUsers.users(user_table, {visibility = {displayed = false}})) -end - ----------------------- --- Credential Table -- ----------------------- -local function has_credentials(device, userIdx) - -- Get latest credential table - local cred_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockCredentials.ID, - capabilities.lockCredentials.credentials.NAME, - {} - )) - - -- Find credential - for index, entry in pairs(cred_table) do - if entry.userIndex == userIdx then - return true - end - end - - -- Get latest Aliro credential table - local aliro_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockAliro.ID, - capabilities.lockAliro.credentials.NAME, - {} - )) - - -- Find Aliro credential - for index, entry in pairs(aliro_table) do - if entry.userIndex == userIdx then - return true - end - end - - return false -end - -local function add_credential_to_table(device, userIdx, credIdx, credType) - -- Get latest credential table - local cred_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockCredentials.ID, - capabilities.lockCredentials.credentials.NAME, - {} - )) - - -- Add new entry to table - table.insert(cred_table, {userIndex = userIdx, credentialIndex = credIdx, credentialType = credType}) - device:emit_event(capabilities.lockCredentials.credentials(cred_table, {visibility = {displayed = false}})) -end - -local function delete_credential_from_table(device, credIdx) - -- If Credential Index is ALL_INDEX, remove all entries from the table - if credIdx == ALL_INDEX then - device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) - return ALL_INDEX - end - - -- Get latest credential table - local cred_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockCredentials.ID, - capabilities.lockCredentials.credentials.NAME, - {} - )) - - -- Delete an entry from credential table - local userIdx = nil - for index, entry in pairs(cred_table) do - if entry.credentialIndex == credIdx then - table.remove(cred_table, index) - userIdx = entry.userIndex - break - end - end - - device:emit_event(capabilities.lockCredentials.credentials(cred_table, {visibility = {displayed = false}})) - return userIdx -end - -local function delete_credential_from_table_as_user(device, userIdx) - -- If User Index is ALL_INDEX, remove all entry from the table - if userIdx == ALL_INDEX then - device:emit_event(capabilities.lockCredentials.credentials({}, {visibility = {displayed = false}})) - return - end - - -- Get latest credential table - local cred_table = device:get_latest_state( - "main", - capabilities.lockCredentials.ID, - capabilities.lockCredentials.credentials.NAME - ) or {} - local new_cred_table = {} - - -- Re-create credential table - for index, entry in pairs(cred_table) do - if entry.userIndex ~= userIdx then - table.insert(new_cred_table, entry) - end - end - - device:emit_event(capabilities.lockCredentials.credentials(new_cred_table, {visibility = {displayed = false}})) -end - ------------------------------ --- Week Day Schedule Table -- ------------------------------ -local function add_week_schedule_to_table(device, userIdx, scheduleIdx, schedule) - -- Get latest week day schedule table - local week_schedule_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockSchedules.ID, - capabilities.lockSchedules.weekDaySchedules.NAME, - {} - )) - - -- Find schedule for specific user - local i = 0 - for index, entry in pairs(week_schedule_table) do - if entry.userIndex == userIdx then - i = index - end - end - - -- Re-create weekDays list - local weekDayList = {} - for _, weekday in ipairs(schedule.weekDays) do - table.insert(weekDayList, weekday) - end - - if i ~= 0 then -- Add schedule for existing user - -- Exclude same scheduleIdx - local new_schedule_table = {} - for index, entry in pairs(week_schedule_table[i].schedules) do - if entry.scheduleIndex ~= scheduleIdx then - table.insert(new_schedule_table, entry) - end - end - -- Add new entry to table - table.insert( - new_schedule_table, - { - scheduleIndex = scheduleIdx, - weekDays = weekDayList, - startHour = schedule.startHour, - startMinute = schedule.startMinute, - endHour = schedule.endHour, - endMinute = schedule.endMinute - } - ) - -- Update schedule for specific user - week_schedule_table[i].schedules = new_schedule_table - else -- Add schedule for new user - table.insert( - week_schedule_table, - { - userIndex = userIdx, - schedules = {{ - scheduleIndex = scheduleIdx, - weekDays = weekDayList, - startHour = schedule.startHour, - startMinute = schedule.startMinute, - endHour = schedule.endHour, - endMinute = schedule.endMinute - }} - } - ) - end - - device:emit_event(capabilities.lockSchedules.weekDaySchedules(week_schedule_table, {visibility = {displayed = false}})) -end - -local function delete_week_schedule_from_table(device, userIdx, scheduleIdx) - -- Get latest week day schedule table - local week_schedule_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockSchedules.ID, - capabilities.lockSchedules.weekDaySchedules.NAME, - {} - )) - - -- Find schedule for specific user - local i = 0 - for index, entry in pairs(week_schedule_table) do - if entry.userIndex == userIdx then - i = index - end - end - - -- When there is no userIndex in the table - if i == 0 then - return - end - - -- Re-create schedule table for the user - local new_schedule_table = {} - for index, entry in pairs(week_schedule_table[i].schedules) do - if entry.scheduleIndex ~= scheduleIdx then - table.insert(new_schedule_table, entry) - end - end - - -- If user has no schedule, remove user from the table - if #new_schedule_table == 0 then - table.remove(week_schedule_table, i) - else - week_schedule_table[i].schedules = new_schedule_table - end - - device:emit_event(capabilities.lockSchedules.weekDaySchedules(week_schedule_table, {visibility = {displayed = false}})) -end - -local function delete_week_schedule_from_table_as_user(device, userIdx) - -- If User Index is ALL_INDEX, remove all entry from the table - if userIdx == ALL_INDEX then - device:emit_event(capabilities.lockSchedules.weekDaySchedules({}, {visibility = {displayed = false}})) - return - end - - -- Get latest week day schedule table - local week_schedule_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockSchedules.ID, - capabilities.lockSchedules.weekDaySchedules.NAME, - {} - )) - - -- Re-create week day schedule table - local new_week_schedule_table = {} - for index, entry in pairs(week_schedule_table) do - if entry.userIndex ~= userIdx then - table.insert(new_week_schedule_table, entry) - end - end - - device:emit_event(capabilities.lockSchedules.weekDaySchedules(new_week_schedule_table, {visibility = {displayed = false}})) -end - ------------------------------ --- Year Day Schedule Table -- ------------------------------ -local function add_year_schedule_to_table(device, userIdx, scheduleIdx, sTime, eTime) - -- Get latest year day schedule table - local year_schedule_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockSchedules.ID, - capabilities.lockSchedules.yearDaySchedules.NAME, - {} - )) - - -- Find schedule for specific user - local i = 0 - for index, entry in pairs(year_schedule_table) do - if entry.userIndex == userIdx then - i = index - end - end - - if i ~= 0 then -- Add schedule for existing user - -- Exclude same scheduleIdx - local new_schedule_table = {} - for index, entry in pairs(year_schedule_table[i].schedules) do - if entry.scheduleIndex ~= scheduleIdx then - table.insert(new_schedule_table, entry) - end - end - -- Add new entry to table - table.insert( - new_schedule_table, - { - scheduleIndex = scheduleIdx, - localStartTime = sTime, - localEndTime = eTime - } - ) - -- Update schedule for specific user - year_schedule_table[i].schedules = new_schedule_table - else -- Add schedule for new user - table.insert( - year_schedule_table, - { - userIndex = userIdx, - schedules = {{ - scheduleIndex = scheduleIdx, - localStartTime = sTime, - localEndTime = eTime - }} - } - ) - end - - device:emit_event(capabilities.lockSchedules.yearDaySchedules(year_schedule_table, {visibility = {displayed = false}})) -end - -local function delete_year_schedule_from_table(device, userIdx, scheduleIdx) - -- Get latest year day schedule table - local year_schedule_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockSchedules.ID, - capabilities.lockSchedules.yearDaySchedules.NAME, - {} - )) - - -- Find schedule for specific user - local i = 0 - for index, entry in pairs(year_schedule_table) do - if entry.userIndex == userIdx then - i = index - end - end - - -- When there is no userIndex in the table - if i == 0 then - return - end - - -- Re-create year day schedule table for the user - local new_schedule_table = {} - for index, entry in pairs(year_schedule_table[i].schedules) do - if entry.scheduleIndex ~= scheduleIdx then - table.insert(new_schedule_table, entry) - end - end - - -- If user has no schedule, remove user from the table - if #new_schedule_table == 0 then - table.remove(year_schedule_table, i) - else - year_schedule_table[i].schedules = new_schedule_table - end - - device:emit_event(capabilities.lockSchedules.yearDaySchedules(year_schedule_table, {visibility = {displayed = false}})) -end - -local function delete_year_schedule_from_table_as_user(device, userIdx) - -- If User Index is ALL_INDEX, remove all entry from the table - if userIdx == ALL_INDEX then - device:emit_event(capabilities.lockSchedules.yearDaySchedules({}, {visibility = {displayed = false}})) - return - end - - -- Get latest year day schedule table - local year_schedule_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockSchedules.ID, - capabilities.lockSchedules.yearDaySchedules.NAME, - {} - )) - - -- Re-create year day schedule table - local new_year_schedule_table = {} - for index, entry in pairs(year_schedule_table) do - if entry.userIndex ~= userIdx then - table.insert(new_year_schedule_table, entry) - end - end - - device:emit_event(capabilities.lockSchedules.yearDaySchedules(new_year_schedule_table, {visibility = {displayed = false}})) -end - ----------------------------- --- Aliro Credential Table -- ----------------------------- -local function add_aliro_to_table(device, userIdx, keyIdx, keyType, keyId) - -- Get latest aliro table - local aliro_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockAliro.ID, - capabilities.lockAliro.credentials.NAME, - {} - )) - - -- Add new entry to table - table.insert(aliro_table, {userIndex = userIdx, keyIndex = keyIdx, keyType = keyType, keyId = keyId}) - device:emit_event(capabilities.lockAliro.credentials(aliro_table, {visibility = {displayed = false}})) -end - -local function delete_aliro_from_table(device, userIdx, keyType, keyId) - -- Get latest aliro table - local aliro_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockAliro.ID, - capabilities.lockAliro.credentials.NAME, - {} - )) - - -- Delete an entry from aliro table - if keyType == "issuerKey" then - for i, entry in pairs(aliro_table) do - if entry.userIndex == userIdx then - table.remove(aliro_table, i) - break - end - end - else - for i, entry in pairs(aliro_table) do - if entry.userIndex == userIdx and entry.keyId == keyId then - table.remove(aliro_table, i) - break - end - end - end - device:emit_event(capabilities.lockAliro.credentials(aliro_table, {visibility = {displayed = false}})) -end - -local function delete_aliro_from_table_as_user(device, userIdx) - -- If User Index is ALL_INDEX, remove all entry from the table - if userIdx == ALL_INDEX then - device:emit_event(capabilities.lockAliro.credentials({}, {visibility = {displayed = false}})) - return - end - - -- Get latest credential table - local aliro_table = device:get_latest_state( - "main", - capabilities.lockAliro.ID, - capabilities.lockAliro.credentials.NAME - ) or {} - local new_aliro_table = {} - - -- Re-create credential table - for index, entry in pairs(aliro_table) do - if entry.userIndex ~= userIdx then - table.insert(new_aliro_table, entry) - end - end - - device:emit_event(capabilities.lockAliro.credentials(new_aliro_table, {visibility = {displayed = false}})) -end - --------------- --- Add User -- --------------- -local function handle_add_user(driver, device, command) - -- Get parameters - local cmdName = "addUser" - local userName = command.args.userName - local userType = command.args.userType - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) - device:set_field(lock_utils.USER_NAME, userName, {persist = true}) - device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) - - -- Get available user index - local ep = device:component_to_endpoint(command.component) - device:send(DoorLock.server.commands.GetUser(device, ep, INITIAL_CREDENTIAL_INDEX)) -end - ------------------ --- Update User -- ------------------ -local function handle_update_user(driver, device, command) - -- Get parameters - local cmdName = "updateUser" - local userIdx = command.args.userIndex - local userName = command.args.userName - local userType = command.args.userType - local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER - if userType == "guest" then - userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER - end - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.USER_NAME, userName, {persist = true}) - device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetUser( - device, ep, - DoorLock.types.DataOperationTypeEnum.MODIFY, -- Operation Type: Add(0), Modify(2) - userIdx, -- User Index - userName, -- User Name - nil, -- Unique ID - nil, -- User Status - userTypeMatter, -- User Type - nil -- Credential Rule - ) - ) -end - ------------------------ --- Get User Response -- ------------------------ -local function get_user_response_handler(driver, device, ib, response) - local elements = ib.info_block.data.elements - local userIdx = elements.user_index.value - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - local status = "success" - if ib.status == DoorLock.types.DlStatus.FAILURE then - status = "failure" - elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then - status = "invalidCommand" - end - if status ~= "success" then - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - statusCode = status - } - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - end - - local ep = find_default_endpoint(device, DoorLock.ID) - local status = elements.user_status.value - local maxUser = device:get_latest_state( - "main", - capabilities.lockUsers.ID, - capabilities.lockUsers.totalUsersSupported.NAME - ) or 10 - - -- Found available user index - if status == nil or status == DoorLock.types.UserStatusEnum.AVAILABLE then - local userName = device:get_field(lock_utils.USER_NAME) - local userType = device:get_field(lock_utils.USER_TYPE) - local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER - if userType == "guest" then - userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER - end - - -- Save values to field - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - - -- Send command - device:send( - DoorLock.server.commands.SetUser( - device, ep, - DoorLock.types.DataOperationTypeEnum.ADD, -- Operation Type: Add(0), Modify(2) - userIdx, -- User Index - userName, -- User Name - nil, -- Unique ID - nil, -- User Status - userTypeMatter, -- User Type - nil -- Credential Rule - ) - ) - elseif userIdx >= maxUser then -- There's no available user index - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - statusCode = "resourceExhausted" - } - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - else -- Check next user index - device:send(DoorLock.server.commands.GetUser(device, ep, userIdx + 1)) - end -end - ------------------------ --- Set User Response -- ------------------------ -local function set_user_response_handler(driver, device, ib, response) - -- Get result - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - local userIdx = device:get_field(lock_utils.USER_INDEX) - local userName = device:get_field(lock_utils.USER_NAME) - local userType = device:get_field(lock_utils.USER_TYPE) - local status = "success" - if ib.status == DoorLock.types.DlStatus.FAILURE then - status = "failure" - elseif ib.status == DoorLock.types.DlStatus.OCCUPIED then - status = "occupied" - elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then - status = "invalidCommand" - end - - -- Update User in table - if status == "success" then - if cmdName == "addUser" then - add_user_to_table(device, userIdx, userName, userType) - elseif cmdName == "updateUser" then - update_user_in_table(device, userIdx, userName, userType) - end - else - device.log.warn(string.format("Failed to set user: %s", status)) - end - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - statusCode = status - } - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) -end - ------------------ --- Delete User -- ------------------ -local function handle_delete_user(driver, device, command) - -- Get parameters - local cmdName = "deleteUser" - local userIdx = command.args.userIndex - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send(DoorLock.server.commands.ClearUser(device, ep, userIdx)) -end - ----------------------- --- Delete All Users -- ----------------------- -local function handle_delete_all_users(driver, device, command) - -- Get parameters - local cmdName = "deleteAllUsers" - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, ALL_INDEX, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send(DoorLock.server.commands.ClearUser(device, ep, ALL_INDEX)) -end - -------------------------- --- Clear User Response -- -------------------------- -local function clear_user_response_handler(driver, device, ib, response) - -- Get result - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - local userIdx = device:get_field(lock_utils.USER_INDEX) - local status = "success" - if ib.status == DoorLock.types.DlStatus.FAILURE then - status = "failure" - elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then - status = "invalidCommand" - end - - -- Delete User and Credential from table - if status == "success" then - delete_user_from_table(device, userIdx) - delete_credential_from_table_as_user(device, userIdx) - delete_aliro_from_table_as_user(device, userIdx) - delete_week_schedule_from_table_as_user(device, userIdx) - delete_year_schedule_from_table_as_user(device, userIdx) - else - device.log.warn(string.format("Failed to clear user: %s", status)) - end - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - statusCode = status - } - device:emit_event(capabilities.lockUsers.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) -end - --------------------- --- Add Credential -- --------------------- -local function handle_add_credential(driver, device, command) - -- Get parameters - local cmdName = "addCredential" - local userIdx = command.args.userIndex - if userIdx == 0 then - userIdx = nil - end - local userType = command.args.userType - local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER - if userType == "guest" then - userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER - end - local credential = { - credential_type = DoorLock.types.CredentialTypeEnum.PIN, - credential_index = INITIAL_CREDENTIAL_INDEX - } - local credData = command.args.credentialData - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.USER_TYPE, userType, {persist = true}) - device:set_field(lock_utils.CRED_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) - device:set_field(lock_utils.CRED_DATA, credData, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetCredential( - device, ep, - DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) - credential, -- Credential - credData, -- Credential Data - userIdx, -- User Index - nil, -- User Status - userTypeMatter -- User Type - ) - ) -end - ------------------------ --- Update Credential -- ------------------------ -local function handle_update_credential(driver, device, command) - -- Get parameters - local cmdName = "updateCredential" - local userIdx = command.args.userIndex - local credIdx = command.args.credentialIndex - local credential = { - credential_type = DoorLock.types.CredentialTypeEnum.PIN, - credential_index = credIdx - } - local credData = command.args.credentialData - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetCredential( - device, ep, - DoorLock.types.DataOperationTypeEnum.MODIFY, -- Data Operation Type: Add(0), Modify(2) - credential, -- Credential - credData, -- Credential Data - userIdx, -- User Index - nil, -- User Status - nil -- User Type - ) - ) -end - ---------------------------------- --- Set Pin Credential Response -- ---------------------------------- -local function set_pin_response_handler(driver, device, ib, response) - if ib.status ~= im.InteractionResponse.Status.SUCCESS then - device.log.error("Failed to set credential for device") - return - end - - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - local credData = device:get_field(lock_utils.CRED_DATA) - if cmdName == "addCota" then - credData = device:get_field(lock_utils.COTA_CRED) - end - local userIdx = device:get_field(lock_utils.USER_INDEX) - local userType = device:get_field(lock_utils.USER_TYPE) - local credIdx = device:get_field(lock_utils.CRED_INDEX) - local elements = ib.info_block.data.elements - local status = RESPONSE_STATUS_MAP[elements.status.value] - - if status == "success" then - -- Don't save user and credential for COTA - if cmdName == "addCota" then - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - return - end - - -- If user is added also, update User table - if userIdx == nil then - 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 - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - credentialIndex = credIdx, - statusCode = status - } - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - - -- If User Type is Guest and device support schedule, add default schedule - local week_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.WEEK_DAY_ACCESS_SCHEDULES}) - local year_schedule_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.YEAR_DAY_ACCESS_SCHEDULES}) - if userType == "guest" and (#week_schedule_eps > 0 or #year_schedule_eps > 0) then - local cmdName = "defaultSchedule" - local scheduleIdx = 1 - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) - - local ep = device:component_to_endpoint("main") - device:send( - DoorLock.server.commands.SetYearDaySchedule( - device, ep, - scheduleIdx, - userIdx, - MIN_EPOCH_S, - MAX_EPOCH_S - ) - ) - else - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - end - return - end - - -- In the case DlStatus returns Occupied, this means the current credential index is in use, - -- so we must try the next one. If there is not a next index (i.e. it is nil), - -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. - device.log.warn(string.format("Failed to set credential: %s", status)) - if status == "occupied" and elements.next_credential_index.value == nil then - local command_result_info = { - commandName = cmdName, - statusCode = "resourceExhausted" -- No more available credential index - } - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - elseif status == "occupied" then - -- Get parameters - local credIdx = elements.next_credential_index.value - local credential = { - credential_type = DoorLock.types.CredentialTypeEnum.PIN, - credential_index = credIdx, - } - local userIdx = device:get_field(lock_utils.USER_INDEX) - local userType = device:get_field(lock_utils.USER_TYPE) - local userTypeMatter = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER - if userIdx ~= nil then - userTypeMatter = nil - elseif userType == "guest" then - userTypeMatter = DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER - elseif userType == "remote" then - userTypeMatter = DoorLock.types.UserTypeEnum.REMOTE_ONLY_USER - end - - device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) - - -- Send command - local ep = find_default_endpoint(device, DoorLock.ID) - device:send( - DoorLock.server.commands.SetCredential( - device, ep, - DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) - credential, -- Credential - credData, -- Credential Data - userIdx, -- User Index - nil, -- User Status - userTypeMatter -- User Type - ) - ) - elseif status == "duplicate" and cmdName == "addCota" then - generate_cota_cred_for_device(device) - device.thread:call_with_delay(0, function(t) set_cota_credential(device, credIdx) end) - else - local command_result_info = { - commandName = cmdName, - statusCode = status - } - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - end -end - ------------------------------------ --- Set Aliro Credential Response -- ------------------------------------ -local function set_issuer_key_response_handler(driver, device, ib, response) - local cmdName = "setIssuerKey" - local userIdx = device:get_field(lock_utils.USER_INDEX) - local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER - local issuerKeyIndex = device:get_field(lock_utils.ISSUER_KEY_INDEX) - local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) - local elements = ib.info_block.data.elements - local status = RESPONSE_STATUS_MAP[elements.status.value] - - if status == "success" then - -- Delete field data - device:set_field(lock_utils.ISSUER_KEY, nil, {persist = true}) - - -- If user is added also, update User table - if userIdx == nil then - userIdx = elements.user_index.value - add_user_to_table(device, userIdx, nil, "adminMember") - end - - -- Update Aliro table - add_aliro_to_table(device, userIdx, issuerKeyIndex, "issuerKey", nil) - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - requestId = reqId, - statusCode = status - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - return - end - - -- In the case DlStatus returns Occupied, this means the current credential index is in use, - -- so we must try the next one. If there is not a next index (i.e. it is nil), - -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. - device.log.warn(string.format("Failed to set credential: %s", status)) - if status == "occupied" and elements.next_credential_index.value == nil then - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - requestId = reqId, - statusCode = "resourceExhausted" -- No more available credential index - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - elseif status == "occupied" then - -- Get parameters - if userIdx ~= nil then - userType = nil - end - local credIdx = elements.next_credential_index.value - local credType = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY - local credData = device:get_field(lock_utils.ISSUER_KEY) - local credential = { - credential_type = credType, - credential_index = credIdx - } - - -- Save values to field - device:set_field(lock_utils.ISSUER_KEY_INDEX, credIdx, {persist = true}) - - -- Send command - local ep = find_default_endpoint(device, DoorLock.ID) - device:send( - DoorLock.server.commands.SetCredential( - device, ep, - DoorLock.types.DataOperationTypeEnum.ADD, - credential, -- Credential - hex_string_to_octet_string(credData), -- Credential Data - userIdx, -- User Index - nil, -- User Status - userType -- User Type - ) - ) - else - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - requestId = reqId, - statusCode = status - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - end -end - -local function set_endpoint_key_response_handler(driver, device, ib, response) - local cmdName = "setEndpointKey" - local userIdx = device:get_field(lock_utils.USER_INDEX) - local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER - local keyId = device:get_field(lock_utils.DEVICE_KEY_ID) - local keyType = device:get_field(lock_utils.ENDPOINT_KEY_TYPE) - local endpointKeyIndex = device:get_field(lock_utils.ENDPOINT_KEY_INDEX) - local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) - local elements = ib.info_block.data.elements - local status = RESPONSE_STATUS_MAP[elements.status.value] - - if status == "success" then - -- Delete field data - device:set_field(lock_utils.ENDPOINT_KEY, nil, {persist = true}) - - -- If user is added also, update User table - if userIdx == nil then - userIdx = elements.user_index.value - add_user_to_table(device, userIdx, nil, "adminMember") - end - - -- Update Aliro table - add_aliro_to_table(device, userIdx, endpointKeyIndex, keyType, keyId) - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - keyId = keyId, - requestId = reqId, - statusCode = status - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - return - end - - -- In the case DlStatus returns Occupied, this means the current credential index is in use, - -- so we must try the next one. If there is not a next index (i.e. it is nil), - -- we should mark this as "resourceExhausted" and stop attempting to set the credentials. - device.log.warn(string.format("Failed to set credential: %s", status)) - - if status == "occupied" and elements.next_credential_index.value == nil then - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - keyId = keyId, - requestId = reqId, - statusCode = "resourceExhausted" -- No more available credential index - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - elseif status == "occupied" then - -- Get parameters - if userIdx ~= nil then - userType = nil - end - local credIdx = elements.next_credential_index.value - local credType = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType] - local credData = device:get_field(lock_utils.ENDPOINT_KEY) - local credential = { - credential_type = credType, - credential_index = credIdx - } - - -- Save values to field - device:set_field(lock_utils.ENDPOINT_KEY_INDEX, credIdx, {persist = true}) - - -- Send command - local ep = find_default_endpoint(device, DoorLock.ID) - device:send( - DoorLock.server.commands.SetCredential( - device, ep, - DoorLock.types.DataOperationTypeEnum.ADD, - credential, -- Credential - hex_string_to_octet_string(credData), -- Credential Data - userIdx, -- User Index - nil, -- User Status - userType -- User Type - ) - ) - else - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - keyId = keyId, - requestId = reqId, - statusCode = status - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) - end -end - -local function set_credential_response_handler(driver, device, ib, response) - if ib.status ~= im.InteractionResponse.Status.SUCCESS then - device.log.error("Failed to set credential for device") - return - end - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - if cmdName == "addCredential" or cmdName == "updateCredential" or cmdName == "addCota" then - set_pin_response_handler(driver, device, ib, response) - elseif cmdName == "setIssuerKey" then - set_issuer_key_response_handler(driver, device, ib, response) - elseif cmdName == "setEndpointKey" then - set_endpoint_key_response_handler(driver, device, ib, response) - end -end - ------------------------ --- Delete Credential -- ------------------------ -local function handle_delete_credential(driver, device, command) - -- Get parameters - local cmdName = "deleteCredential" - local credIdx = command.args.credentialIndex - local credential = { - credential_type = DoorLock.types.CredentialTypeEnum.PIN, - credential_index = credIdx, - } - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.CRED_INDEX, credIdx, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) -end - ----------------------------- --- Delete All Credentials -- ----------------------------- -local function handle_delete_all_credentials(driver, device, command) - -- Get parameters - local cmdName = "deleteAllCredentials" - local credential = { - credential_type = DoorLock.types.CredentialTypeEnum.PIN, - credential_index = ALL_INDEX, - } - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.CRED_INDEX, ALL_INDEX, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) -end - -------------------------------- --- Clear Credential Response -- -------------------------------- -local function clear_credential_response_handler(driver, device, ib, response) - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - if cmdName ~= "deleteCredential" and cmdName ~= "clearEndpointKey" and - cmdName ~= "clearIssuerKey" and cmdName ~= "deleteAllCredentials" then - return - end - local status = RESPONSE_STATUS_MAP[ib.status] or "success" - local command_result_info = { commandName = cmdName, statusCode = status } -- default command result - local userIdx = device:get_field(lock_utils.USER_INDEX) - local all_user_credentials_removed = false - - if (cmdName == "deleteCredential" or cmdName == "deleteAllCredentials") and status == "success" then - -- Get result from data saved in relevant, associated fields - local credIdx = device:get_field(lock_utils.CRED_INDEX) - - -- find userIdx associated with credIdx, don't use lock utils field in this case - userIdx = delete_credential_from_table(device, credIdx) - if userIdx ~= nil then - all_user_credentials_removed = not has_credentials(device, userIdx) - end - - -- set unique command result fields - command_result_info.userIndex = userIdx - command_result_info.credentialIndex = credIdx - elseif cmdName == "clearIssuerKey" and status == "success" then - -- Get result from data saved in relevant, associated fields - local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) - - delete_aliro_from_table(device, userIdx, "issuerKey", nil) - all_user_credentials_removed = not has_credentials(device, userIdx) - - -- set unique command result fields - command_result_info.userIndex = userIdx - command_result_info.requestId = reqId - elseif cmdName == "clearEndpointKey" and status == "success" then - -- Get result from data saved in relevant, associated fields - local deviceKeyId = device:get_field(lock_utils.DEVICE_KEY_ID) - local keyType = device:get_field(lock_utils.ENDPOINT_KEY_TYPE) - local reqId = device:get_field(lock_utils.COMMAND_REQUEST_ID) - - delete_aliro_from_table(device, userIdx, keyType, deviceKeyId) - all_user_credentials_removed = not has_credentials(device, userIdx) - - -- set unique command result fields - command_result_info.userIndex = userIdx - command_result_info.keyId = deviceKeyId - command_result_info.requestId = reqId - end - - -- user data if credentials were removed - if all_user_credentials_removed then - delete_user_from_table(device, userIdx) - delete_week_schedule_from_table_as_user(device, userIdx) - delete_year_schedule_from_table_as_user(device, userIdx) - end - - -- Update commandResult - if cmdName == "deleteCredential" or cmdName == "deleteAllCredentials" then - device:emit_event(capabilities.lockCredentials.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - else - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - end - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) -end - ---------------------------- --- Set Week Day Schedule -- ---------------------------- -local function handle_set_week_day_schedule(driver, device, command) - -- Get parameters - local cmdName = "setWeekDaySchedule" - local scheduleIdx = command.args.scheduleIndex - local userIdx = command.args.userIndex - local schedule = command.args.schedule - local wDays = {} - local scheduleBit = 0 - for _, weekDay in ipairs(schedule.weekDays) do - scheduleBit = scheduleBit + WEEK_DAY_MAP[weekDay] - table.insert(wDays, weekDay) - end - local startHour = schedule.startHour - local startMinute = schedule.startMinute - local endHour = schedule.endHour - local endMinute = schedule.endMinute - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockSchedules.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) - device:set_field(lock_utils.SCHEDULE_WEEK_DAYS, wDays, {persist = true}) - device:set_field(lock_utils.SCHEDULE_START_HOUR, startHour, {persist = true}) - device:set_field(lock_utils.SCHEDULE_START_MINUTE, startMinute, {persist = true}) - device:set_field(lock_utils.SCHEDULE_END_HOUR, endHour, {persist = true}) - device:set_field(lock_utils.SCHEDULE_END_MINUTE, endMinute, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetWeekDaySchedule( - device, ep, - scheduleIdx, -- Week Day Schedule Index - userIdx, -- User Index - scheduleBit, -- Days Mask - startHour, -- Start Hour - startMinute, -- Start Minute - endHour, -- End Hour - endMinute -- End Minute - ) - ) -end - ------------------------------------- --- Set Week Day Schedule Response -- ------------------------------------- -local function set_week_day_schedule_handler(driver, device, ib, response) - -- Get result - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - local userIdx = device:get_field(lock_utils.USER_INDEX) - local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) - local days = device:get_field(lock_utils.SCHEDULE_WEEK_DAYS) - local sHour = device:get_field(lock_utils.SCHEDULE_START_HOUR) - local sMinute = device:get_field(lock_utils.SCHEDULE_START_MINUTE) - local eHour = device:get_field(lock_utils.SCHEDULE_END_HOUR) - local eMinute = device:get_field(lock_utils.SCHEDULE_END_MINUTE) - local schedule = { - weekDays = days, - startHour = sHour, - startMinute = sMinute, - endHour = eHour, - endMinute = eMinute - } - local status = "success" - if ib.status == DoorLock.types.DlStatus.FAILURE then - status = "failure" - elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then - status = "invalidCommand" - end - - -- Add Week Day Schedule to table - if status == "success" then - add_week_schedule_to_table(device, userIdx, scheduleIdx, schedule) - else - device.log.warn(string.format("Failed to set week day schedule: %s", status)) - end - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - scheduleIndex = scheduleIdx, - statusCode = status - } - device:emit_event(capabilities.lockSchedules.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) -end - ------------------------------ --- Clear Week Day Schedule -- ------------------------------ -local function handle_clear_week_day_schedule(driver, device, command) - -- Get parameters - local cmdName = "clearWeekDaySchedules" - local scheduleIdx = command.args.scheduleIndex - local userIdx = command.args.userIndex - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockSchedules.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send(DoorLock.server.commands.ClearWeekDaySchedule(device, ep, scheduleIdx, userIdx)) -end - ------------------------------------- --- Clear Week Day Schedule Response -- ------------------------------------- -local function clear_week_day_schedule_handler(driver, device, ib, response) - -- Get result - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) - local userIdx = device:get_field(lock_utils.USER_INDEX) - local status = "success" - if ib.status == DoorLock.types.DlStatus.FAILURE then - status = "failure" - elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then - status = "invalidCommand" - end - - -- Delete Week Day Schedule to table - if status == "success" then - delete_week_schedule_from_table(device, userIdx, scheduleIdx) - else - device.log.warn(string.format("Failed to clear week day schedule: %s", status)) - end - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - scheduleIndex = scheduleIdx, - statusCode = status - } - device:emit_event(capabilities.lockSchedules.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) -end - --- This type represents an offset, in seconds, from 0 hours, 0 minutes, 0 seconds, on the 1st of January, 2000 UTC -local function iso8601_to_epoch(iso_str) - local pattern = "^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)" - local year, month, day, hour, min, sec = iso_str:match(pattern) - if not year then - return nil - end - local epoch_s = os.time({ - year = tonumber(year), - month = tonumber(month), - day = tonumber(day), - hour = tonumber(hour), - min = tonumber(min), - sec = tonumber(sec), - }) - - -- The os.time() is based on 1970. Thirty years must be subtracted for calculations from 2000. - epoch_s = epoch_s - THIRTY_YEARS_S - - if epoch_s < MIN_EPOCH_S then - return MIN_EPOCH_S - elseif epoch_s > MAX_EPOCH_S then - return MAX_EPOCH_S - else - return epoch_s - end -end - ---------------------------- --- Set Year Day Schedule -- ---------------------------- -local function handle_set_year_day_schedule(driver, device, command) - -- Get parameters - local cmdName = "setYearDaySchedule" - local scheduleIdx = command.args.scheduleIndex - local userIdx = command.args.userIndex - local localStartTime = command.args.schedule.localStartTime - local localEndTime = command.args.schedule.localEndTime - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockSchedules.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) - device:set_field(lock_utils.SCHEDULE_LOCAL_START_TIME, localStartTime, {persist = true}) - device:set_field(lock_utils.SCHEDULE_LOCAL_END_TIME, localEndTime, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetYearDaySchedule( - device, ep, - scheduleIdx, - userIdx, - iso8601_to_epoch(localStartTime), - iso8601_to_epoch(localEndTime) - ) - ) -end - ------------------------------------- --- Set Year Day Schedule Response -- ------------------------------------- -local function set_year_day_schedule_handler(driver, device, ib, response) - -- Get result - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - local userIdx = device:get_field(lock_utils.USER_INDEX) - local scheduleIdx = device:get_field(lock_utils.SCHEDULE_INDEX) - local localStartTime = device:get_field(lock_utils.SCHEDULE_LOCAL_START_TIME) - local localEndTime = device:get_field(lock_utils.SCHEDULE_LOCAL_END_TIME) - local status = "success" - if ib.status == DoorLock.types.DlStatus.FAILURE then - status = "failure" - elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then - status = "invalidCommand" - end - - if cmdName == "defaultSchedule" then - return - end - - if status == "success" then - if cmdName == "setYearDaySchedule" then - add_year_schedule_to_table(device, userIdx, scheduleIdx, localStartTime, localEndTime) - elseif cmdName == "clearYearDaySchedules" then - delete_year_schedule_from_table(device, userIdx, scheduleIdx) - end - else - device.log.warn(string.format("Failed to set/clear year day schedule: %s", status)) - end - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - scheduleIndex = scheduleIdx, - statusCode = status - } - device:emit_event(capabilities.lockSchedules.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) -end - ------------------------------ --- Clear Year Day Schedule -- ------------------------------ -local function handle_clear_year_day_schedule(driver, device, command) - -- Get parameters - local cmdName = "clearYearDaySchedules" - local scheduleIdx = command.args.scheduleIndex - local userIdx = command.args.userIndex - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockSchedules.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.SCHEDULE_INDEX, scheduleIdx, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - - -- Send command - -- In SmartThings, Schedule Restrict User is basically allowed to access always. - -- So, if user delete the year day schedule, enter an infinitely long schedule. - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetYearDaySchedule( - device, ep, - scheduleIdx, - userIdx, - MIN_EPOCH_S, - MAX_EPOCH_S - ) - ) -end - ----------------- --- Lock Alarm -- ----------------- -local function alarm_event_handler(driver, device, ib, response) - local DlAlarmCode = DoorLock.types.DlAlarmCode - local alarm_code = ib.data.elements.alarm_code - if alarm_code.value == DlAlarmCode.LOCK_JAMMED then - device:emit_event(capabilities.lockAlarm.alarm.unableToLockTheDoor({state_change = true})) - elseif alarm_code.value == DlAlarmCode.LOCK_FACTORY_RESET then - device:emit_event(capabilities.lockAlarm.alarm.lockFactoryReset({state_change = true})) - elseif alarm_code.value == DlAlarmCode.WRONG_CODE_ENTRY_LIMIT then - device:emit_event(capabilities.lockAlarm.alarm.attemptsExceeded({state_change = true})) - elseif alarm_code.value == DlAlarmCode.FRONT_ESCEUTCHEON_REMOVED then - device:emit_event(capabilities.lockAlarm.alarm.damaged({state_change = true})) - elseif alarm_code.value == DlAlarmCode.DOOR_FORCED_OPEN then - device:emit_event(capabilities.lockAlarm.alarm.forcedOpeningAttempt({state_change = true})) - end -end - --------------------- --- Lock Operation -- --------------------- -local function lock_op_event_handler(driver, device, ib, response) - local opType = ib.data.elements.lock_operation_type - local opSource = ib.data.elements.operation_source - local userIdx = ib.data.elements.user_index - -- TODO: This handler can check fabric index and exclude other fabric events - - if opType == nil or opSource == nil then - return - end - - local Type = DoorLock.types.LockOperationTypeEnum - local Lock = capabilities.lock.lock - if opType.value == Type.LOCK then - opType = Lock.locked - elseif opType.value == Type.UNLOCK then - opType = Lock.unlocked - elseif opType.value == Type.UNLATCH then - opType = Lock.unlatched - else - return - end - - local Source = DoorLock.types.OperationSourceEnum - if opSource.value == Source.UNSPECIFIED then - opSource = nil - elseif opSource.value == Source.MANUAL then - opSource = "manual" - elseif opSource.value == Source.PROPRIETARY_REMOTE then - opSource = "proprietaryRemote" - elseif opSource.value == Source.KEYPAD then - opSource = "keypad" - elseif opSource.value == Source.AUTO then - opSource = "auto" - elseif opSource.value == Source.BUTTON then - opSource = "button" - elseif opSource.value == Source.SCHEDULE then - opSource = nil - elseif opSource.value == Source.REMOTE then - opSource = "command" - elseif opSource.value == Source.RFID then - opSource = "rfid" - elseif opSource.value == Source.BIOMETRIC then - opSource = "keypad" - elseif opSource.value == Source.ALIRO then - opSource = nil - else - opSource =nil - end - - if userIdx ~= nil then - userIdx = userIdx.value - end - - local data_obj = {method = opSource, userIndex = userIdx} - device:emit_event(opType({data = data_obj, state_change = true})) -end - -local function handle_refresh(driver, device, command) - local req = DoorLock.attributes.LockState:read(device) - device:send(req) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) -end - -local function handle_set_reader_config(driver, device, command) - local cmdName = "setReaderConfig" - local signingKey = command.args.signingKey - local verificationKey = command.args.verificationKey - local groupId = command.args.groupId - local groupResolvingKey = nil - local aliro_ble_uwb_eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.Feature.ALIROBLEUWB}) - if #aliro_ble_uwb_eps > 0 then - groupResolvingKey = command.args.groupResolvingKey - end - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - statusCode = "busy" - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.VERIFICATION_KEY, verificationKey, {persist = true}) - device:set_field(lock_utils.GROUP_ID, groupId, {persist = true}) - device:set_field(lock_utils.GROUP_RESOLVING_KEY, groupResolvingKey, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetAliroReaderConfig( - device, ep, - hex_string_to_octet_string(signingKey), - hex_string_to_octet_string(verificationKey), - hex_string_to_octet_string(groupId), -- Group identification - hex_string_to_octet_string(groupResolvingKey) -- Group resolving key - ) - ) -end - -local function set_aliro_reader_config_handler(driver, device, ib, response) - -- Get result - local cmdName = device:get_field(lock_utils.COMMAND_NAME) - local verificationKey = device:get_field(lock_utils.VERIFICATION_KEY) - local groupId = device:get_field(lock_utils.GROUP_ID) - local groupResolvingKey = device:get_field(lock_utils.GROUP_RESOLVING_KEY) - - local status = "success" - if ib.status == DoorLock.types.DlStatus.FAILURE then - status = "failure" - elseif ib.status == DoorLock.types.DlStatus.INVALID_FIELD then - status = "invalidCommand" - elseif ib.status == DoorLock.types.DlStatus.SUCCESS then - if verificationKey ~= nil then - device:emit_event(capabilities.lockAliro.readerVerificationKey( - verificationKey, - { - state_change = true, - visibility = {displayed = false} - } - )) - end - if groupId ~= nil then - device:emit_event(capabilities.lockAliro.readerGroupIdentifier( - groupId, - { - state_change = true, - visibility = {displayed = false} - } - )) - end - if groupResolvingKey ~= nil then - device:emit_event(capabilities.lockAliro.groupResolvingKey( - groupResolvingKey, - { - state_change = true, - visibility = {displayed = false} - } - )) - end - end - - -- Update commandResult - local command_result_info = { - commandName = cmdName, - statusCode = status - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) -end - -local function handle_set_card_id(driver, device, command) - if command.args.cardId ~= nil then - device:emit_event(capabilities.lockAliro.cardId(command.args.cardId, {visibility = {displayed = false}})) - end -end - -local function handle_set_issuer_key(driver, device, command) - -- Get parameters - local cmdName = "setIssuerKey" - local userIdx = command.args.userIndex - local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER - local issuerKey = command.args.issuerKey - local reqId = command.args.requestId - local credential = { - credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, - credential_index = INITIAL_CREDENTIAL_INDEX - } - - -- Adjustment - if userIdx == 0 then - userIdx = nil - else - userType = nil - end - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - requestId = reqId, - statusCode = "busy" - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.ISSUER_KEY, issuerKey, {persist = true}) - device:set_field(lock_utils.ISSUER_KEY_INDEX, INITIAL_CREDENTIAL_INDEX, {persist = true}) - device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetCredential( - device, ep, - DoorLock.types.DataOperationTypeEnum.ADD, -- Data Operation Type: Add(0), Modify(2) - credential, -- Credential - hex_string_to_octet_string(issuerKey), -- Credential Data - userIdx, -- User Index - nil, -- User Status - userType -- User Type - ) - ) -end - -local function handle_clear_issuer_key(driver, device, command) - -- Get parameters - local cmdName = "clearIssuerKey" - local userIdx = command.args.userIndex - local reqId = command.args.requestId - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - requestId = reqId, - statusCode = "busy" - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) - - -- Get latest aliro table - local aliro_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockAliro.ID, - capabilities.lockAliro.credentials.NAME, - {} - )) - - -- Find issuer key index - for index, entry in pairs(aliro_table) do - if entry.userIndex == userIdx and entry.keyType == "issuerKey" then - -- Set parameters - local credential = { - credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, - credential_index = entry.keyIndex, - } - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) - break - end - end -end - -local function handle_set_endpoint_key(driver, device, command) - -- Get parameters - local cmdName = "setEndpointKey" - local userIdx = command.args.userIndex - local userType = DoorLock.types.UserTypeEnum.UNRESTRICTED_USER - local keyId = command.args.keyId - local keyType = command.args.keyType - local endpointKey = command.args.endpointKey - local reqId = command.args.requestId - local dataOpType = DoorLock.types.DataOperationTypeEnum.ADD -- Data Operation Type: Add(0), Modify(2) - local endpointKeyIndex = INITIAL_CREDENTIAL_INDEX - - -- Min user index of commandResult is 1 - -- 0 should convert to nil before busy check - if userIdx == 0 then - userIdx = nil - end - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - keyId = keyId, - requestId = reqId, - statusCode = "busy" - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Adjustment - if userIdx ~= nil then - userType = nil - - -- Get latest aliro table - local aliro_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockAliro.ID, - capabilities.lockAliro.credentials.NAME, - {} - )) - - -- Find existing endpoint key - for index, entry in pairs(aliro_table) do - if (entry.keyType == "evictableEndpointKey" or entry.keyType == "nonEvictableEndpointKey") and entry.keyId == keyId then - dataOpType = DoorLock.types.DataOperationTypeEnum.MODIFY - endpointKeyIndex = entry.keyIndex - delete_aliro_from_table(device, userIdx, keyType, keyId) - break - end - end - end - - local credential = { - credential_type = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType], - credential_index = endpointKeyIndex - } - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.DEVICE_KEY_ID, keyId, {persist = true}) - device:set_field(lock_utils.ENDPOINT_KEY_TYPE, keyType, {persist = true}) - device:set_field(lock_utils.ENDPOINT_KEY, endpointKey, {persist = true}) - device:set_field(lock_utils.ENDPOINT_KEY_INDEX, endpointKeyIndex, {persist = true}) - device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) - - -- Send command - local ep = device:component_to_endpoint(command.component) - device:send( - DoorLock.server.commands.SetCredential( - device, ep, - dataOpType, -- Data Operation Type: Add(0), Modify(2) - credential, -- Credential - hex_string_to_octet_string(endpointKey), -- Credential Data - userIdx, -- User Index - nil, -- User Status - userType -- User Type - ) - ) -end - -local function handle_clear_endpoint_key(driver, device, command) - -- Get parameters - local cmdName = "clearEndpointKey" - local userIdx = command.args.userIndex - local keyId = command.args.keyId - local keyType = command.args.keyType - local reqId = command.args.requestId - - -- Check busy state - if is_busy_state_set(device) then - local command_result_info = { - commandName = cmdName, - userIndex = userIdx, - keyId = keyId, - requestId = reqId, - statusCode = "busy" - } - device:emit_event(capabilities.lockAliro.commandResult( - command_result_info, {state_change = true, visibility = {displayed = false}} - )) - return - end - - -- Save values to field - device:set_field(lock_utils.COMMAND_NAME, cmdName, {persist = true}) - device:set_field(lock_utils.USER_INDEX, userIdx, {persist = true}) - device:set_field(lock_utils.DEVICE_KEY_ID, keyId, {persist = true}) - device:set_field(lock_utils.ENDPOINT_KEY_TYPE, keyType, {persist = true}) - device:set_field(lock_utils.COMMAND_REQUEST_ID, reqId, {persist = true}) - - -- Get latest aliro table - local aliro_table = utils.deep_copy(device:get_latest_state( - "main", - capabilities.lockAliro.ID, - capabilities.lockAliro.credentials.NAME, - {} - )) - - local ep = device:component_to_endpoint(command.component) - if keyId == nil then - return - else - -- Find aliro credential - for index, entry in pairs(aliro_table) do - if entry.userIndex == userIdx and entry.keyId == keyId and entry.keyType == keyType then - -- Set parameters - local credential = { - credential_type = ALIRO_KEY_TYPE_TO_CRED_ENUM_MAP[keyType], - credential_index = entry.keyIndex, - } - -- Send command - device:send(DoorLock.server.commands.ClearCredential(device, ep, credential)) - break - end - end - end -end - -local new_matter_lock_handler = { - NAME = "New Matter Lock Handler", - lifecycle_handlers = { - init = device_init, - added = device_added, - doConfigure = do_configure, - infoChanged = info_changed, - driverSwitched = driver_switched - }, - matter_handlers = { - attr = { - [DoorLock.ID] = { - [DoorLock.attributes.LockState.ID] = lock_state_handler, - [DoorLock.attributes.OperatingMode.ID] = operating_modes_handler, - [DoorLock.attributes.NumberOfTotalUsersSupported.ID] = total_users_supported_handler, - [DoorLock.attributes.NumberOfPINUsersSupported.ID] = pin_users_supported_handler, - [DoorLock.attributes.MinPINCodeLength.ID] = min_pin_code_len_handler, - [DoorLock.attributes.MaxPINCodeLength.ID] = max_pin_code_len_handler, - [DoorLock.attributes.RequirePINforRemoteOperation.ID] = require_remote_pin_handler, - [DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser.ID] = max_week_schedule_of_user_handler, - [DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser.ID] = max_year_schedule_of_user_handler, - [DoorLock.attributes.AliroReaderVerificationKey.ID] = aliro_reader_verification_key_handler, - [DoorLock.attributes.AliroReaderGroupIdentifier.ID] = aliro_reader_group_id_handler, - [DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions.ID] = aliro_protocol_versions_handler, - [DoorLock.attributes.AliroGroupResolvingKey.ID] = aliro_group_resolving_key_handler, - [DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions.ID] = aliro_supported_ble_uwb_protocol_versions_handler, - [DoorLock.attributes.AliroBLEAdvertisingVersion.ID] = aliro_ble_advertising_version_handler, - [DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported.ID] = max_aliro_credential_issuer_key_handler, - [DoorLock.attributes.NumberOfAliroEndpointKeysSupported.ID] = max_aliro_endpoint_key_handler, - }, - [PowerSource.ID] = { - [PowerSource.attributes.AttributeList.ID] = handle_power_source_attribute_list, - [PowerSource.attributes.BatPercentRemaining.ID] = handle_battery_percent_remaining, - [PowerSource.attributes.BatChargeLevel.ID] = handle_battery_charge_level, - } - }, - event = { - [DoorLock.ID] = { - [DoorLock.events.DoorLockAlarm.ID] = alarm_event_handler, - [DoorLock.events.LockOperation.ID] = lock_op_event_handler, - }, - }, - cmd_response = { - [DoorLock.ID] = { - [DoorLock.server.commands.SetUser.ID] = set_user_response_handler, - [DoorLock.client.commands.GetUserResponse.ID] = get_user_response_handler, - [DoorLock.server.commands.ClearUser.ID] = clear_user_response_handler, - [DoorLock.client.commands.SetCredentialResponse.ID] = set_credential_response_handler, - [DoorLock.server.commands.ClearCredential.ID] = clear_credential_response_handler, - [DoorLock.server.commands.SetWeekDaySchedule.ID] = set_week_day_schedule_handler, - [DoorLock.server.commands.ClearWeekDaySchedule.ID] = clear_week_day_schedule_handler, - [DoorLock.server.commands.SetYearDaySchedule.ID] = set_year_day_schedule_handler, - [DoorLock.server.commands.SetAliroReaderConfig.ID] = set_aliro_reader_config_handler, - }, - }, - }, - subscribed_attributes = subscribed_attributes, - subscribed_events = subscribed_events, - capability_handlers = { - [capabilities.lock.ID] = { - [capabilities.lock.commands.lock.NAME] = handle_lock, - [capabilities.lock.commands.unlock.NAME] = handle_unlock, - [capabilities.lock.commands.unlatch.NAME] = handle_unlatch - }, - [capabilities.lockUsers.ID] = { - [capabilities.lockUsers.commands.addUser.NAME] = handle_add_user, - [capabilities.lockUsers.commands.updateUser.NAME] = handle_update_user, - [capabilities.lockUsers.commands.deleteUser.NAME] = handle_delete_user, - [capabilities.lockUsers.commands.deleteAllUsers.NAME] = handle_delete_all_users, - }, - [capabilities.lockCredentials.ID] = { - [capabilities.lockCredentials.commands.addCredential.NAME] = handle_add_credential, - [capabilities.lockCredentials.commands.updateCredential.NAME] = handle_update_credential, - [capabilities.lockCredentials.commands.deleteCredential.NAME] = handle_delete_credential, - [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = handle_delete_all_credentials, - }, - [capabilities.lockSchedules.ID] = { - [capabilities.lockSchedules.commands.setWeekDaySchedule.NAME] = handle_set_week_day_schedule, - [capabilities.lockSchedules.commands.clearWeekDaySchedules.NAME] = handle_clear_week_day_schedule, - [capabilities.lockSchedules.commands.setYearDaySchedule.NAME] = handle_set_year_day_schedule, - [capabilities.lockSchedules.commands.clearYearDaySchedules.NAME] = handle_clear_year_day_schedule, - }, - [capabilities.lockAliro.ID] = { - [capabilities.lockAliro.commands.setReaderConfig.NAME] = handle_set_reader_config, - [capabilities.lockAliro.commands.setCardId.NAME] = handle_set_card_id, - [capabilities.lockAliro.commands.setIssuerKey.NAME] = handle_set_issuer_key, - [capabilities.lockAliro.commands.clearIssuerKey.NAME] = handle_clear_issuer_key, - [capabilities.lockAliro.commands.setEndpointKey.NAME] = handle_set_endpoint_key, - [capabilities.lockAliro.commands.clearEndpointKey.NAME] = handle_clear_endpoint_key, - }, - [capabilities.refresh.ID] = {[capabilities.refresh.commands.refresh.NAME] = handle_refresh} - }, - supported_capabilities = { - capabilities.lock, - capabilities.lockUsers, - capabilities.lockCredentials, - capabilities.lockSchedules, - capabilities.battery, - capabilities.batteryLevel - }, - can_handle = is_new_matter_lock_products -} - -return new_matter_lock_handler diff --git a/drivers/SmartThings/matter-lock/src/sub_drivers.lua b/drivers/SmartThings/matter-lock/src/sub_drivers.lua new file mode 100644 index 0000000000..563c428945 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("unmigrated-matter-lock"), +} +return sub_drivers diff --git a/drivers/SmartThings/matter-lock/src/test/test_aqara_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_aqara_matter_lock.lua index 5c4add0ff5..7c6bc2e783 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_aqara_matter_lock.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_aqara_matter_lock.lua @@ -1,22 +1,13 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" test.set_rpc_version(0) local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("lock-user-pin.yml"), @@ -52,6 +43,7 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = clusters.DoorLock.ID} local function test_init() test.disable_startup_messages() -- subscribe request @@ -62,6 +54,7 @@ local function test_init() subscribe_request:merge(clusters.DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device)) subscribe_request:merge(clusters.DoorLock.attributes.MinPINCodeLength:subscribe(mock_device)) subscribe_request:merge(clusters.DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_device)) subscribe_request:merge(clusters.DoorLock.events.LockUserChange:subscribe(mock_device)) @@ -100,6 +93,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, clusters.DoorLock.server.commands.LockDoor(mock_device, 1)}, }, + }, + { + min_api_version = 14 } ) @@ -121,6 +117,9 @@ test.register_message_test( clusters.DoorLock.server.commands.UnlockDoor(mock_device, 1), }, }, + }, + { + min_api_version = 14 } ) @@ -140,7 +139,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.locked()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -159,7 +161,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -178,7 +183,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.not_fully_locked()) ) - end + end, + { + min_api_version = 14 + } ) local function refresh_commands(dev) @@ -201,6 +209,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, refresh_commands(mock_device)}, }, + }, + { + min_api_version = 14 } ) @@ -297,6 +308,9 @@ test.register_message_test( capabilities.lockAlarm.alarm.forcedOpeningAttempt({state_change = true}) ), }, + }, + { + min_api_version = 14 } ) @@ -311,7 +325,10 @@ test.register_coroutine_test( capabilities.lockAlarm.alarm.clear({state_change = true}) ) ) -end +end, +{ + min_api_version = 14 +} ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_bridged_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_bridged_matter_lock.lua index 0d17cafda6..0ce5d58404 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_bridged_matter_lock.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_bridged_matter_lock.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -80,17 +70,15 @@ local function test_init() test.socket.matter:__expect_send({mock_device.id, req}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) - subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_device)) subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) - subscribe_request:merge(clusters.DoorLock.events.LockUserChange:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) test.mock_device.add_test_device(mock_device) - test.mock_device.add_test_device(mock_device_level) test.socket.device_lifecycle:__queue_receive({ mock_device_level.id, "added" }) test.socket.device_lifecycle:__queue_receive({ mock_device_level.id, "init" }) local subscribe_request_level = clusters.DoorLock.attributes.LockState:subscribe(mock_device_level) + subscribe_request_level:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device_level)) test.socket["matter"]:__expect_send({mock_device_level.id, subscribe_request_level}) end test.set_test_init_function(test_init) @@ -101,7 +89,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update({ profile = "base-lock-nobattery" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -109,7 +100,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device_level.id, "doConfigure" }) mock_device_level:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua index 0db34172d5..580bd34420 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -52,7 +42,6 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) - subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_device)) subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) @@ -79,6 +68,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, clusters.DoorLock.server.commands.LockDoor(mock_device, 10)}, }, + }, + { + min_api_version = 15 } ) @@ -100,6 +92,9 @@ test.register_message_test( clusters.DoorLock.server.commands.UnlockDoor(mock_device, 10), }, }, + }, + { + min_api_version = 15 } ) @@ -120,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()), }, + }, + { + min_api_version = 15 } ) @@ -140,6 +138,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()), }, + }, + { + min_api_version = 15 } ) @@ -160,6 +161,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.not_fully_locked()), }, + }, + { + min_api_version = 15 } ) @@ -180,6 +184,32 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()), }, + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Handle unknown LockState value from Matter device.", { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.DoorLock.attributes.LockState:build_test_report_data( + mock_device, 10, 0xFF + ), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()), + }, + }, + { + min_api_version = 15 } ) @@ -202,6 +232,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 15 } ) @@ -226,6 +259,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, refresh_commands(mock_device)}, }, + }, + { + min_api_version = 15 } ) @@ -292,6 +328,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()), }, + }, + { + min_api_version = 15 } ) @@ -329,6 +368,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()), }, + }, + { + min_api_version = 15 } ) @@ -341,7 +383,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) -end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_battery.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_battery.lua index 8e0be6359d..9113374514 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_battery.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_battery.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -77,9 +67,7 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) - subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_device)) subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) - subscribe_request:merge(clusters.DoorLock.events.LockUserChange:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) @@ -98,9 +86,7 @@ local function test_init_no_battery() test.socket.device_lifecycle:__queue_receive({ mock_device_no_battery.id, "init" }) local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device_no_battery) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device_no_battery)) - subscribe_request:merge(clusters.DoorLock.events.DoorLockAlarm:subscribe(mock_device_no_battery)) subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device_no_battery)) - subscribe_request:merge(clusters.DoorLock.events.LockUserChange:subscribe(mock_device_no_battery)) test.socket["matter"]:__expect_send({mock_device_no_battery.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_no_battery.id, "doConfigure" }) mock_device_no_battery:expect_metadata_update({ profile = "base-lock-nobattery" }) @@ -129,7 +115,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ profile = "base-lock" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -154,13 +143,19 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ profile = "base-lock-batteryLevel" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( "Test that profile changes to base-lock-no-battery when battery feature is not available", function() end, - { test_init = test_init_no_battery } + { + test_init = test_init_no_battery, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_batteryLevel.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_batteryLevel.lua index c53547025c..8995332812 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_batteryLevel.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_batteryLevel.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -49,6 +39,7 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = clusters.DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(clusters.PowerSource.attributes.BatChargeLevel:subscribe(mock_device)) + subscribe_request:merge(clusters.DoorLock.events.LockOperation:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) @@ -102,6 +93,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.normal()), }, + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua index 2960d905c2..4ccc4d135b 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_codes.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -55,9 +45,7 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) - subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) - subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update({ profile = "base-lock-nobattery" }) @@ -233,7 +221,10 @@ test.register_coroutine_test( req:merge(DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device, 10)) test.socket.matter:__expect_send({mock_device.id, req}) expect_reload_all_codes_messages(mock_device) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -246,7 +237,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -267,6 +261,9 @@ test.register_message_test( capabilities.lockCodes.minCodeLength(4, {visibility = {displayed = false}}) ), }, + }, + { + min_api_version = 15 } ) @@ -288,6 +285,9 @@ test.register_message_test( capabilities.lockCodes.maxCodeLength(4, {visibility = {displayed = false}}) ), }, + }, + { + min_api_version = 15 } ) @@ -309,6 +309,9 @@ test.register_message_test( capabilities.lockCodes.maxCodes(16, {visibility = {displayed = false}}) ), }, + }, + { + min_api_version = 15 } ) @@ -328,6 +331,9 @@ test.register_message_test( ), }, } + }, + { + min_api_version = 15 } ) @@ -350,6 +356,9 @@ test.register_message_test( ), }, } + }, + { + min_api_version = 15 } ) @@ -362,7 +371,10 @@ test.register_coroutine_test( } ) expect_reload_all_codes_messages(mock_device) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -404,7 +416,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -439,7 +454,10 @@ test.register_coroutine_test( .codeChanged("1 unset", {data = {codeName = "Code 1"}, state_change = true}) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -542,7 +560,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -588,7 +609,10 @@ test.register_coroutine_test( capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -645,7 +669,10 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "test"}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -677,7 +704,10 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "foo"}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( "Setting a user code name via setCode should be handled", function() @@ -708,7 +738,10 @@ test.register_coroutine_test( .lockCodes(json.encode({["1"] = "foo"}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -747,6 +780,9 @@ test.register_message_test( ) ), }, + }, + { + min_api_version = 15 } ) @@ -787,7 +823,10 @@ test.register_coroutine_test( capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -863,7 +902,76 @@ test.register_coroutine_test( ) ) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) +local enabled_optional_component_capability_pairs = {{ + "main", + { + capabilities.lockUsers.ID, + capabilities.lockCredentials.ID, + capabilities.battery.ID, + } +}} + +test.register_coroutine_test( + "Lock codes stored during migration", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({["1"] = "Code 1"}), {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + init_code_slot(2, "Code 2", mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({["1"] = "Code 1", ["2"] = "Code 2"}), {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + init_code_slot(3, "Code 3", mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.lockCodes.lockCodes( + json.encode({["1"] = "Code 1", ["2"] = "Code 2", ["3"] = "Code 3"}), + {visibility = {displayed = false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCodes.ID, + command = "migrate", + args = {} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = enabled_optional_component_capability_pairs }) + + end, + { + min_api_version = 15 + } +) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_cota.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_cota.lua index 57468ef2fc..9e39655943 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_cota.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_cota.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -54,9 +44,7 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device)) - subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) - subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) test.socket.matter:__expect_send({mock_device.id, DoorLock.attributes.RequirePINforRemoteOperation:read(mock_device, 10)}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) @@ -153,7 +141,10 @@ test.register_coroutine_test( "Added should kick off cota cred process", function() test.socket.matter:__set_channel_ordering("relaxed") expect_kick_off_cota_process(mock_device) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -167,7 +158,10 @@ test.register_coroutine_test( mock_device.id, clusters.DoorLock.server.commands.UnlockDoor(mock_device, 10, "1111"), }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -181,7 +175,10 @@ test.register_coroutine_test( mock_device.id, clusters.DoorLock.server.commands.LockDoor(mock_device, 10, "1111"), }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -213,7 +210,10 @@ test.register_coroutine_test( DoorLock.types.DlUserType.REMOTE_ONLY_USER -- user_type ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -234,7 +234,10 @@ test.register_coroutine_test( profile = "nonfunctional-lock", provisioning_state = "NONFUNCTIONAL" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -333,7 +336,10 @@ test.register_coroutine_test( ) local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -428,7 +434,10 @@ test.register_coroutine_test( ) local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -516,7 +525,10 @@ test.register_coroutine_test( ) local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -573,7 +585,10 @@ test.register_coroutine_test( DoorLock.types.DlUserType.REMOTE_ONLY_USER -- user_type ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -610,7 +625,10 @@ test.register_coroutine_test( ) }) test.mock_time.advance_time(2) - end + end, + { + min_api_version = 15 + } ) @@ -688,7 +706,10 @@ test.register_coroutine_test( DoorLock.types.DlUserType.REMOTE_ONLY_USER -- user_type ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -720,12 +741,18 @@ test.register_coroutine_test( DoorLock.types.DlUserType.REMOTE_ONLY_USER -- user_type ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( "Delay setting COTA cred if another cred is already being set.", function() - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_migration.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_migration.lua new file mode 100644 index 0000000000..8e1f731599 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_migration.lua @@ -0,0 +1,143 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local DoorLock = clusters.DoorLock +local lock_utils = require "lock_utils" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + manufacturer_info = { + vendor_id = 0x135D, + product_id = 0x00C1, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0101, -- PIN & USR + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + -- mock_device:set_field(lock_utils.LOCK_CODES_FOR_MIGRATION, { {1, "ST Remote Operation Code"}, {2, "Guest1"}, {3, "Guest2"} }, {persist=true}) +end + +test.set_test_init_function(test_init) + +local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) +subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfPINUsersSupported:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) + +local enabled_optional_component_capability_pairs = {{ + "main", + { + capabilities.lockUsers.ID, + capabilities.lockCredentials.ID, + capabilities.battery.ID, + } +}} + +test.register_coroutine_test( + "Migration completed and User and Credential values restored", + function() + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = capabilities.lockCodes.ID, command = "migrate", args = {}} + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true)) + ) + -- at this point, refresh is injected in migration. + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = enabled_optional_component_capability_pairs }) + + + test.wait_for_events() + -- assume this was set prior + mock_device:set_field(lock_utils.LOCK_CODES_FOR_MIGRATION, { {1, "ST Remote Operation Code"}, {2, "Guest1"}, {3, "Guest2"} }, {persist=true}) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed( + {profile = {id = "00000000-1111-2222-3333-000000000010", components = { main = {capabilities={ + ["lock"]= {id="lock", version=1}, ["lockAlarm"] = {id="lockAlarm", version=1}, ["remoteControlStatus"] = {id="remoteControlStatus", version=1}, + ["lockUsers"] = {id="lockUsers", version=1}, ["lockCredentials"] = {id="lockCredentials", version=1}, ["firmwareUpdate"] = {id="firmwareUpdate", version=1}, + ["refresh"] = {id="refresh", version=1}}}}}} + )) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + { + {userIndex=1, userName="ST Remote Operation Code", userType="guest"}, + {userIndex=2, userName="Guest1", userType="guest"}, + {userIndex=3, userName="Guest2", userType="guest"} + }, + {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + { + {credentialIndex=1, credentialType="pin", userIndex=1}, + {credentialIndex=2, credentialType="pin", userIndex=2}, + {credentialIndex=3, credentialType="pin", userIndex=3} + }, + {visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_modular.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_modular.lua index d6382faa06..ca1aedfb04 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_modular.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_modular.lua @@ -1,23 +1,12 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" local t_utils = require "integration_test.utils" local uint32 = require "st.matter.data_types.Uint32" - +local cluster_base = require "st.matter.cluster_base" local DoorLock = clusters.DoorLock local mock_device = test.mock_device.build_test_matter_device({ @@ -210,14 +199,17 @@ local mock_device_modular = test.mock_device.build_test_matter_device({ } }) - +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} local function test_init() test.disable_startup_messages() -- subscribe request local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device)) + -- add test device test.mock_device.add_test_device(mock_device) -- actual onboarding flow @@ -225,7 +217,6 @@ local function test_init() test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) @@ -237,8 +228,11 @@ local function test_init_unlatch() -- subscribe request local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_unlatch) subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_unlatch)) + subscribe_request:merge(cluster_base.subscribe(mock_device_unlatch, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_unlatch)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_unlatch)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_unlatch)) + -- add test device, handle initial subscribe test.mock_device.add_test_device(mock_device_unlatch) -- actual onboarding flow @@ -246,7 +240,6 @@ local function test_init_unlatch() test.socket.capability:__expect_send( mock_device_unlatch:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device_unlatch.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "init" }) test.socket.matter:__expect_send({mock_device_unlatch.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "doConfigure" }) @@ -263,9 +256,12 @@ local function test_init_user_pin() subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device_user_pin)) subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device_user_pin)) subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_user_pin)) + subscribe_request:merge(cluster_base.subscribe(mock_device_user_pin, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_user_pin)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_user_pin)) subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_user_pin)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_user_pin)) + -- add test device test.mock_device.add_test_device(mock_device_user_pin) -- actual onboarding flow @@ -273,7 +269,6 @@ local function test_init_user_pin() test.socket.capability:__expect_send( mock_device_user_pin:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device_user_pin.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "init" }) test.socket.matter:__expect_send({mock_device_user_pin.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "doConfigure" }) @@ -292,9 +287,12 @@ local function test_init_user_pin_schedule_unlatch() subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_user_pin_schedule_unlatch)) subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device_user_pin_schedule_unlatch)) subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(cluster_base.subscribe(mock_device_user_pin_schedule_unlatch, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_user_pin_schedule_unlatch)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_user_pin_schedule_unlatch)) subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_user_pin_schedule_unlatch)) + -- add test device test.mock_device.add_test_device(mock_device_user_pin_schedule_unlatch) -- actual onboarding flow @@ -302,7 +300,6 @@ local function test_init_user_pin_schedule_unlatch() test.socket.capability:__expect_send( mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device_user_pin_schedule_unlatch.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "init" }) test.socket.matter:__expect_send({mock_device_user_pin_schedule_unlatch.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "doConfigure" }) @@ -314,8 +311,11 @@ local function test_init_modular() -- subscribe request local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_modular) subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_modular)) + subscribe_request:merge(cluster_base.subscribe(mock_device_modular, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_modular)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_modular)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_modular)) + -- add test device test.mock_device.add_test_device(mock_device_modular) -- actual onboarding flow @@ -323,7 +323,6 @@ local function test_init_modular() test.socket.capability:__expect_send( mock_device_modular:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device_modular.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "init" }) test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "doConfigure" }) @@ -332,36 +331,6 @@ end test.set_test_init_function(test_init) -test.register_coroutine_test( - "Test lock profile change when attributes related to BAT feature is not available.", - function() - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, - { - uint32(0), - uint32(1), - uint32(2), - uint32(31), - uint32(65528), - uint32(65529), - uint32(65531), - uint32(65532), - uint32(65533), - }) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) - ) - mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = {{"main", {}}} }) - end -) - test.register_coroutine_test( "Test modular lock profile change when BatChargeLevel attribute is available", function() @@ -390,7 +359,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) ) mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = {{"main", {"batteryLevel"}}} }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -422,38 +394,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) ) mock_device:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = {{"main", {"battery"}}} }) - end -) - -test.register_coroutine_test( - "Test modular lock profile change with unlatch when attributes related to BAT feature is not available.", - function() - test.socket.matter:__queue_receive( - { - mock_device_unlatch.id, - clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_unlatch, 1, - { - uint32(0), - uint32(1), - uint32(2), - uint32(31), - uint32(65528), - uint32(65529), - uint32(65531), - uint32(65532), - uint32(65533), - }) - } - ) - test.socket.capability:__expect_send( - mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) - ) - test.socket.capability:__expect_send( - mock_device_unlatch:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) - ) - mock_device_unlatch:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {}}} }) end, - { test_init = test_init_unlatch } + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -485,7 +429,10 @@ test.register_coroutine_test( ) mock_device_unlatch:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"batteryLevel"}}} }) end, - { test_init = test_init_unlatch } + { + test_init = test_init_unlatch, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -518,7 +465,10 @@ test.register_coroutine_test( ) mock_device_unlatch:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"battery"}}} }) end, - { test_init = test_init_unlatch } + { + test_init = test_init_unlatch, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -551,7 +501,10 @@ test.register_coroutine_test( ) mock_device_user_pin:expect_metadata_update({ profile = "lock-modular", optional_component_capabilities = {{"main", {"lockUsers", "lockCredentials", "battery"}}} }) end, - { test_init = test_init_user_pin } + { + test_init = test_init_user_pin, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -585,7 +538,10 @@ test.register_coroutine_test( mock_device_user_pin_schedule_unlatch:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"lockUsers", "lockCredentials", "lockSchedules", "battery"}}} }) end, - { test_init = test_init_user_pin_schedule_unlatch } + { + test_init = test_init_user_pin_schedule_unlatch, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -617,16 +573,32 @@ test.register_coroutine_test( mock_device_modular:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) ) mock_device_modular:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"lockUsers", "lockCredentials", "lockSchedules", "battery"}}} }) + end, + { + test_init = test_init_modular, + min_api_version = 15 + } +) - local updated_device_profile = t_utils.get_profile_definition("lock-modular-embedded-unlatch.yml", - {enabled_optional_capabilities = {{ "main", {"lockUsers", "lockCredentials", "lockSchedules", "battery"}}, - },} - ) - updated_device_profile.id = "00000000-1111-2222-3333-000000000010" - - test.wait_for_events() - test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({ profile = updated_device_profile })) +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({})) + end, + { + test_init = test_init_modular, + min_api_version = 15 + } +) +test.register_coroutine_test( + "Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", function() + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed( + {profile = {id = "00000000-1111-2222-3333-000000000010", components = { main = {capabilities={ + ["lock"]= {id="lock", version=1}, ["lockAlarm"] = {id="lockAlarm", version=1}, ["remoteControlStatus"] = {id="remoteControlStatus", version=1}, + ["lockUsers"] = {id="lockUsers", version=1}, ["lockCredentials"] = {id="lockCredentials", version=1}, ["lockSchedules"] = {id="lockSchedules", version=1}, + ["battery"] = {id="battery", version=1}, ["firmwareUpdate"] = {id="firmwareUpdate", version=1}, ["refresh"] = {id="refresh", version=1}}}}}}) + ) local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_modular) subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_modular)) subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device_modular)) @@ -636,10 +608,13 @@ test.register_coroutine_test( subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_modular)) subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device_modular)) subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device_modular)) + subscribe_request:merge(cluster_base.subscribe(mock_device_modular, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_modular)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_modular)) subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_modular)) subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device_modular)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_modular)) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) test.socket.capability:__expect_send( mock_device_modular:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) @@ -648,7 +623,83 @@ test.register_coroutine_test( mock_device_modular:generate_test_message("main", capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) ) end, - { test_init = test_init_modular } + { + test_init = test_init_modular, + min_api_version = 15 + } +) + + +local mock_nuki_smart_lock_ultra = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-unlatch.yml"), + manufacturer_info = { + vendor_id = 0x135D, + product_id = 0x00A1, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x1000, -- UNBOLT + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" +} + +local profiling_data = { + BATTERY_SUPPORT = "__BATTERY_SUPPORT", +} + +test.register_coroutine_test( + "Test Nuki Smart Lock Ultra profile change with user and pin supported", + function() + -- technically, since power source attributes must be read, this wouldn't be running via doConfigure, but this is straightforward. + test.socket.device_lifecycle:__queue_receive({ mock_nuki_smart_lock_ultra.id, "doConfigure" }) + test.socket.capability:__expect_send( + mock_nuki_smart_lock_ultra:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "unlatched", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_nuki_smart_lock_ultra:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) + ) + mock_nuki_smart_lock_ultra:expect_metadata_update({ profile = "lock-modular-embedded-unlatch", optional_component_capabilities = {{"main", {"battery"}}}}) + mock_nuki_smart_lock_ultra:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_nuki_smart_lock_ultra) + mock_nuki_smart_lock_ultra:set_field(profiling_data.BATTERY_SUPPORT, battery_support.BATTERY_PERCENTAGE, {persist = true}) -- assume this has been set previously + end, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_unlatch.lua b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_unlatch.lua index 38154d7b04..f2224af358 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_matter_lock_unlatch.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_matter_lock_unlatch.lua @@ -1,22 +1,13 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" test.set_rpc_version(0) local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" local DoorLock = clusters.DoorLock local types = DoorLock.types @@ -53,11 +44,13 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} local function test_init() test.disable_startup_messages() -- subscribe_request local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) -- add test device, handle initial subscribe @@ -111,7 +104,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock", "unlatch"}, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -159,7 +155,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -177,6 +176,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, DoorLock.server.commands.LockDoor(mock_device, 1)}, }, + }, + { + min_api_version = 14 } ) @@ -198,6 +200,9 @@ test.register_message_test( DoorLock.server.commands.UnboltDoor(mock_device, 1), }, }, + }, + { + min_api_version = 14 } ) @@ -219,6 +224,9 @@ test.register_message_test( DoorLock.server.commands.UnlockDoor(mock_device, 1), }, }, + }, + { + min_api_version = 14 } ) @@ -238,7 +246,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.locked()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -257,7 +268,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -276,7 +290,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.unlatched()) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -362,6 +379,9 @@ test.register_message_test( ) ), } + }, + { + min_api_version = 14 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua index 7d5a59ff32..0054d552ea 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock.lua @@ -1,28 +1,19 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- These are test units designed to validate User, Credential, and Schedule features. local test = require "integration_test" test.set_rpc_version(0) local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" local DoorLock = clusters.DoorLock local types = DoorLock.types local lock_utils = require "lock_utils" local mock_device = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("lock-user-pin-schedule.yml"), + profile = t_utils.get_profile_definition("lock.yml"), manufacturer_info = { vendor_id = 0x115f, product_id = 0x2802, @@ -44,7 +35,7 @@ local mock_device = test.mock_device.build_test_matter_device({ cluster_id = DoorLock.ID, cluster_type = "SERVER", cluster_revision = 1, - feature_map = 0x0181, -- PIN & USR & COTA + feature_map = 0x0591, -- PIN & WDSCH & USR & COTA & YDSCH } }, device_types = { @@ -54,8 +45,15 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} local function test_init() test.disable_startup_messages() + -- device_added + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) -- subscribe request local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) @@ -66,29 +64,42 @@ local function test_init() subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device)) subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device)) subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) - -- add test device, handle initial subscribe - test.mock_device.add_test_device(mock_device) test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) - -- actual onboarding flow - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) - ) + -- device_init test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + -- do_configure test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) ) - mock_device:expect_metadata_update({ profile = "lock-user-pin" }) + mock_device:expect_metadata_update({ profile = "lock-user-pin-schedule" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + -- info_changed + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("lock-user-pin-schedule.yml")})) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Handle driverSwitched event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ profile = "lock-user-pin-schedule" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Handle received OperatingMode(Normal, Vacation) from Matter device.", function() @@ -120,7 +131,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -168,7 +182,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({}, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -185,7 +202,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(10, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -202,7 +222,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(10, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -219,7 +242,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -236,7 +262,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(8, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -253,7 +282,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockSchedules.weekDaySchedulesPerUser(5, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -270,7 +302,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockSchedules.yearDaySchedulesPerUser(5, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -338,7 +373,10 @@ test.register_coroutine_test( ), } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -416,7 +454,10 @@ function() ) test.mock_time.advance_time(1) test.wait_for_events() -end +end, +{ + min_api_version = 14 +} ) test.register_coroutine_test( @@ -430,7 +471,10 @@ test.register_coroutine_test( ), } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -447,7 +491,10 @@ test.register_coroutine_test( mock_device.id, clusters.DoorLock.server.commands.UnlockDoor(mock_device, 1, "654123"), }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -463,7 +510,10 @@ test.register_coroutine_test( mock_device.id, clusters.DoorLock.server.commands.LockDoor(mock_device, 1, "654123"), }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -481,6 +531,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, DoorLock.server.commands.LockDoor(mock_device, 1)}, }, + }, + { + min_api_version = 14 } ) @@ -502,6 +555,9 @@ test.register_message_test( DoorLock.server.commands.UnlockDoor(mock_device, 1), }, }, + }, + { + min_api_version = 14 } ) @@ -521,7 +577,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.locked()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -540,7 +599,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -559,7 +621,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lock.lock.not_fully_locked()) ) - end + end, + { + min_api_version = 14 + } ) local function refresh_commands(dev) @@ -582,6 +647,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, refresh_commands(mock_device)}, }, + }, + { + min_api_version = 14 } ) @@ -678,6 +746,9 @@ test.register_message_test( capabilities.lockAlarm.alarm.forcedOpeningAttempt({state_change = true}) ), }, + }, + { + min_api_version = 14 } ) @@ -871,7 +942,7 @@ test.register_message_test( message = mock_device:generate_test_message( "main", capabilities.lock.lock.unlocked( - {data = {method = "keypad", userIndex = 1}, state_change = true} + {data = {userIndex = 1}, state_change = true} ) ), }, @@ -898,27 +969,16 @@ test.register_message_test( message = mock_device:generate_test_message( "main", capabilities.lock.lock.unlocked( - {data = {userIndex = 1}, state_change = true} + {data = {method = "digitalKey", userIndex = 1}, state_change = true} ) ), } + }, + { + min_api_version = 14 } ) -test.register_coroutine_test( - "Added lifecycle event lock nocodes nobattery", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.lockAlarm.alarm.clear({state_change = true}) - ) - ) - end -) - test.register_coroutine_test( "Handle Add User command received from SmartThings.", function() @@ -982,7 +1042,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1006,7 +1069,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1047,7 +1113,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1093,7 +1162,10 @@ test.register_coroutine_test( ) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1117,7 +1189,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1180,7 +1255,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1204,7 +1282,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1267,7 +1348,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1291,7 +1375,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1319,7 +1406,7 @@ test.register_coroutine_test( "654123", -- credential_data 1, -- user_index nil, -- user_status - DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + nil -- user_type ), } ) @@ -1353,7 +1440,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1379,7 +1469,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1407,7 +1500,7 @@ test.register_coroutine_test( "654123", -- credential_data 1, -- user_index nil, -- user_status - DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + nil -- user_type ), } ) @@ -1432,7 +1525,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1460,7 +1556,7 @@ test.register_coroutine_test( "654123", -- credential_data 1, -- user_index nil, -- user_status - DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + nil -- user_type ), } ) @@ -1492,7 +1588,10 @@ test.register_coroutine_test( ), } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1520,7 +1619,7 @@ test.register_coroutine_test( "654123", -- credential_data 1, -- user_index nil, -- user_status - DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + nil -- user_type ), } ) @@ -1545,19 +1644,113 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Handle Update Credential command received from SmartThings.", + "Update Credential for Guest user should not add default schedule again", function() + -- Add Guest user with credential. This sets the USER_TYPE field to "guest". + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {0, "guest", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex = 1, userType = "guest"}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + {{credentialIndex=1, credentialType="pin", userIndex=1}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- year_day_index + 1, -- user_index + 0, -- local_start_time + 0xffffffff -- local_end_time + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS -- status + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", credentialIndex=1, statusCode="success", userIndex=1}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.wait_for_events() + -- Update the Guest user's credential. The stale "guest" USER_TYPE field must + -- be cleared so the default schedule is not added again on the response. test.socket.capability:__queue_receive( { mock_device.id, { capability = capabilities.lockCredentials.ID, command = "updateCredential", - args = {1, 1, "pin", "654123"} + args = {1, 1, "pin", "111213"} }, } ) @@ -1570,7 +1763,7 @@ test.register_coroutine_test( DoorLock.types.CredentialStruct( {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} ), -- credential - "654123", -- credential_data + "111213", -- credential_data 1, -- user_index nil, -- user_status nil -- user_type @@ -1589,6 +1782,7 @@ test.register_coroutine_test( ), } ) + -- The commandResult must be emitted without sending SetYearDaySchedule test.socket.capability:__expect_send( mock_device:generate_test_message( "main", @@ -1598,7 +1792,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1624,7 +1821,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1675,7 +1875,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1701,7 +1904,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1770,7 +1976,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1796,7 +2005,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1864,7 +2076,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1890,7 +2105,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1937,7 +2155,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1966,7 +2187,430 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } +) + +local LOCAL_START_TIME = "2025-01-15T09:00:00" +local LOCAL_END_TIME = "2025-01-15T17:00:00" + +test.register_coroutine_test( + "Handle Add Year Day Schedule command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "setYearDaySchedule", + args = {1, 1, {localStartTime=LOCAL_START_TIME, localEndTime=LOCAL_END_TIME}} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- Schedule Index + 1, -- User Index + lock_utils.iso8601_to_epoch(LOCAL_START_TIME), -- Start Time (epoch) + lock_utils.iso8601_to_epoch(LOCAL_END_TIME) -- End Time (epoch) + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules( + {{ + userIndex=1, + schedules={{ + scheduleIndex = 1, + localStartTime = LOCAL_START_TIME, + localEndTime = LOCAL_END_TIME + }}, + }}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="setYearDaySchedule", userIndex=1, scheduleIndex=1, statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Add Year Day Schedule command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "setYearDaySchedule", + args = {1, 1, {localStartTime=LOCAL_START_TIME, localEndTime=LOCAL_END_TIME}} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="setYearDaySchedule", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Handle Clear Year Day Schedule command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "clearYearDaySchedules", + args = { + 1, -- user index + 1, -- schedule index + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- year_day_index + 1, -- user_index + 0, -- local_start_time + lock_utils.MAX_EPOCH_S -- local_end_time + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1 + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="clearYearDaySchedules", userIndex=1, scheduleIndex=1, statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Clear Year Day Schedule command received from SmartThings and send busy state", + function() + mock_device:set_field(lock_utils.BUSY_STATE, os.time(), {persist = true}) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockSchedules.ID, + command = "clearYearDaySchedules", + args = { + 1, -- user index + 1, -- schedule index + } + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.commandResult( + {commandName="clearYearDaySchedules", statusCode="busy"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Add Guest User and failure response ", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {0, "guest", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({{userIndex = 1, userType = "guest"}}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + {{credentialIndex=1, credentialType="pin", userIndex=1}}, {visibility={displayed=false}} + ) + ) + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- year_day_index + 1, -- user_index + 0, -- local_start_time + 0xffffffff -- local_end_time + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE -- status + ), + } + ) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.ClearUser( + mock_device, 1, + 1 + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearUser:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", statusCode="failure", userIndex=1}, {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Add Guest User and failure response, and ClearUser command fails as well", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockCredentials.ID, + command = "addCredential", + args = {0, "guest", "pin", "654123"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.PIN, credential_index = 1} + ), -- credential + "654123", -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.UserTypeEnum.SCHEDULE_RESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({{userIndex = 1, userType = "guest"}}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.credentials( + {{credentialIndex=1, credentialType="pin", userIndex=1}}, {visibility={displayed=false}} + ) + ) + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule( + mock_device, 1, -- endpoint + 1, -- year_day_index + 1, -- user_index + 0, -- local_start_time + 0xffffffff -- local_end_time + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetYearDaySchedule:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE -- status + ), + } + ) + test.socket.matter:__expect_send({ + mock_device.id, + DoorLock.server.commands.ClearUser( + mock_device, 1, + 1 + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearUser:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE -- status + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockCredentials.commandResult( + {commandName="addCredential", statusCode="success", userIndex=1}, {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_aliro.lua b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_aliro.lua new file mode 100644 index 0000000000..573dbf0e62 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_aliro.lua @@ -0,0 +1,1305 @@ +-- Copyright 2023 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. + +local FIXTURE_DER_B64 = + "MHcCAQEEIHlEZiE0cRiQ+Jp+RAGQ/Rq8miEBQXfRQNeSlyNR0Cv1oAoGCCqGSM49AwEH" .. + "oUQDQgAEeBuCTahXpt/rGLvVnOxjTlbmNYdKQF3vlHZYMK/LtISNUXJsJ1BfBX9nGgwY" .. + "WlJ775K5woV3zzMW7X4dVV5C7Q==" +local EXPECTED_PRIV_HEX = "7944662134711890f89a7e440190fd1abc9a21014177d140d792972351d02bf5" +local EXPECTED_PUB_HEX = "04781b824da857a6dfeb18bbd59cec634e56e635874a405def94765830afcbb48" .. + "48d51726c27505f057f671a0c185a527bef92b9c28577cf3316ed7e1d555e42ed" +local EXPECTED_GROUP_ID_HEX = "64c8cce93255c4478d7aa05d83f3eaa2" + +local base64 = require "base64" +local function default_generate_self_signed_cert(_opts) + local der = base64.decode(FIXTURE_DER_B64) + return { + cert_pem = "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----", + key_pem = "-----BEGIN EC PRIVATE KEY-----\nfake\n-----END EC PRIVATE KEY-----", + cert_der = der, + key_der = der, + } +end +local security_stub = { + generate_self_signed_cert = default_generate_self_signed_cert, +} +package.loaded["st.security"] = security_stub + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local DoorLock = clusters.DoorLock +local OctetString1 = require "st.matter.data_types.OctetString1" +local lock_utils = require "lock_utils" +lock_utils.create_group_id_resolving_key = function() + return EXPECTED_GROUP_ID_HEX +end + +local key_id = "vTNt0oPoHvIvwGMHa3AuXE3ZcY+Oocv5KZ+R0yveEag=" +local endpoint_key = "041a748a78566aaee985d9141730fa72bd83bf34e7b93072a0ca7b56a79b6debac9493eded05a65701b5148517bd49a6c91c78ed6811543491eff1d257280ed809" +local request_id = "1f3acdf6-8930-45f7-ae3d-f0b47851c3e2" + +local enabled_optional_component_capability_pairs = {{ + "main", + { + capabilities.lockUsers.ID, + capabilities.lockSchedules.ID, + capabilities.lockAliro.ID + } +}} +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition( + "lock-modular.yml", + {enabled_optional_capabilities = enabled_optional_component_capability_pairs} + ), + manufacturer_info = { + vendor_id = 0x135D, + product_id = 0x00C1, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.BasicInformation.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x2510, -- WDSCH & YDSCH & USR & ALIRO + } + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} +local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) +subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfTotalUsersSupported:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroReaderVerificationKey:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroReaderGroupIdentifier:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroReaderGroupSubIdentifier:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroGroupResolvingKey:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.AliroBLEAdvertisingVersion:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported:subscribe(mock_device)) +subscribe_request:merge(DoorLock.attributes.NumberOfAliroEndpointKeysSupported:subscribe(mock_device)) +subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) +subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) +subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device)) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockValues({"locked", "unlocked", "not fully locked"}, {visibility = {displayed = false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) + ) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle received AliroReaderVerificationKey from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroReaderVerificationKey:build_test_report_data( + mock_device, 1, + lock_utils.hex_string_to_octet_string(EXPECTED_PUB_HEX) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.readerVerificationKey(EXPECTED_PUB_HEX, {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle received AliroReaderGroupIdentifier from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroReaderGroupIdentifier:build_test_report_data( + mock_device, 1, + lock_utils.hex_string_to_octet_string(EXPECTED_GROUP_ID_HEX) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.readerGroupIdentifier(EXPECTED_GROUP_ID_HEX, {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle received AliroExpeditedTransactionSupportedProtocolVersions from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroExpeditedTransactionSupportedProtocolVersions:build_test_report_data( + mock_device, 1, + {OctetString1("\x00\x09"), OctetString1("\x01\x00")} + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.expeditedTransactionProtocolVersions( + {"0.9", "1.0"}, + {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle received AliroSupportedBLEUWBProtocolVersions from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroSupportedBLEUWBProtocolVersions:build_test_report_data( + mock_device, 1, + {OctetString1("\x00\x09"), OctetString1("\x01\x00")} + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.bleUWBProtocolVersions( + {"0.9", "1.0"}, + {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle received AliroReaderVerificationKey from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.NumberOfAliroCredentialIssuerKeysSupported:build_test_report_data( + mock_device, 1, + 35 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.maxCredentialIssuerKeys( + 35, + {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle received AliroGroupResolvingKey from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroGroupResolvingKey:build_test_report_data( + mock_device, 1, + lock_utils.hex_string_to_octet_string(EXPECTED_GROUP_ID_HEX) + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.groupResolvingKey(EXPECTED_GROUP_ID_HEX, {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle received AliroBLEAdvertisingVersion from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.AliroBLEAdvertisingVersion:build_test_report_data( + mock_device, 1, + 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.bleAdvertisingVersion( + "1", + {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle received NumberOfAliroEndpointKeysSupported from Matter device.", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.attributes.NumberOfAliroEndpointKeysSupported:build_test_report_data( + mock_device, 1, + 10 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.maxEndpointKeys( + 10, + {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Card Id command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setCardId", + args = {"3icub18c8pr00"} + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.cardId("3icub18c8pr00", {visibility = {displayed = false}}) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Reader Config command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setReaderConfig", + args = { + EXPECTED_PRIV_HEX, + EXPECTED_PUB_HEX, + EXPECTED_GROUP_ID_HEX, + nil + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetAliroReaderConfig( + mock_device, 1, -- endpoint + lock_utils.hex_string_to_octet_string(EXPECTED_PRIV_HEX), + lock_utils.hex_string_to_octet_string(EXPECTED_PUB_HEX), + lock_utils.hex_string_to_octet_string(EXPECTED_GROUP_ID_HEX), + nil + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.SetAliroReaderConfig:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS -- status + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + {commandName="setReaderConfig", statusCode="success"}, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command and Clear Endpoint Key command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex=1, userType="adminMember"}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.credentials( + {{ + keyId=key_id, + keyIndex=1, + keyType="nonEvictableEndpointKey", + userIndex=1 + }}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setEndpointKey", + keyId=key_id, + requestId=request_id, + statusCode="success", + userIndex=1 + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "clearEndpointKey", + args = {1, key_id, "nonEvictableEndpointKey"} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.ClearCredential( + mock_device, 1, -- endpoint + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, credential_index = 1} + ) + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearCredential:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="clearEndpointKey", + keyId=key_id, + statusCode="success", + userIndex=1 + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command received from SmartThings and busy status", + function() + lock_utils.is_busy_state_set(mock_device) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setEndpointKey", + keyId=key_id, + requestId=request_id, + statusCode="busy" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command received from SmartThings and user_index is occupied", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 2 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command received from SmartThings and user_index is occupied and next_credential_index is nil", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + nil -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setEndpointKey", + keyId=key_id, + requestId=request_id, + statusCode="resourceExhausted" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Endpoint Key command received from SmartThings and user_index is failure", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setEndpointKey", + args = { + 0, -- user index + key_id, + "nonEvictableEndpointKey", + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_NON_EVICTABLE_ENDPOINT_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setEndpointKey", + keyId=key_id, + requestId=request_id, + statusCode="failure" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command and Clear Issuer Key command received from SmartThings.", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.SUCCESS, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users( + {{userIndex=1, userType="adminMember"}}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.credentials( + {{ + keyIndex=1, + keyType="issuerKey", + userIndex=1 + }}, + {visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setIssuerKey", + requestId=request_id, + statusCode="success", + userIndex=1 + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "clearIssuerKey", + args = {1, request_id} + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.ClearCredential( + mock_device, 1, -- endpoint + DoorLock.types.CredentialStruct( + {credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, credential_index = 1} + ) + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.server.commands.ClearCredential:build_test_command_response( + mock_device, 1 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.credentials({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockUsers.users({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.weekDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockSchedules.yearDaySchedules({}, {visibility={displayed=false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="clearIssuerKey", + requestId=request_id, + statusCode="success", + userIndex=1 + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command received from SmartThings and busy status", + function() + lock_utils.is_busy_state_set(mock_device) + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setIssuerKey", + requestId=request_id, + statusCode="busy" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command received from SmartThings and user_index is occupied", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 2 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command received from SmartThings and user_index is occupied and next_credential_index is nil", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.OCCUPIED, -- status + 1, -- user_index + nil -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setIssuerKey", + requestId=request_id, + statusCode="resourceExhausted" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle Set Issuer Key command received from SmartThings and user_index is failure", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { + capability = capabilities.lockAliro.ID, + command = "setIssuerKey", + args = { + 0, + endpoint_key, + request_id + } + }, + } + ) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetCredential( + mock_device, 1, -- endpoint + DoorLock.types.DataOperationTypeEnum.ADD, -- operation_type + DoorLock.types.CredentialStruct( + { + credential_type = DoorLock.types.CredentialTypeEnum.ALIRO_CREDENTIAL_ISSUER_KEY, + credential_index = 1 + } + ), -- credential + lock_utils.hex_string_to_octet_string(endpoint_key), -- credential_data + nil, -- user_index + nil, -- user_status + DoorLock.types.DlUserType.UNRESTRICTED_USER -- user_type + ), + } + ) + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.SetCredentialResponse:build_test_command_response( + mock_device, 1, + DoorLock.types.DlStatus.FAILURE, -- status + 1, -- user_index + 2 -- next_credential_index + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setIssuerKey", + requestId=request_id, + statusCode="failure" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "set_reader_config should send SetAliroReaderConfig command on device init", + function() + mock_device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, nil, {persist = true}) + mock_device:set_field(lock_utils.BUSY_STATE, false, {persist = true}) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("lock.yml")})) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.matter:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetAliroReaderConfig( + mock_device, 1, + lock_utils.hex_string_to_octet_string(EXPECTED_PRIV_HEX), + lock_utils.hex_string_to_octet_string(EXPECTED_PUB_HEX), + lock_utils.hex_string_to_octet_string(EXPECTED_GROUP_ID_HEX), + nil + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Set Reader Config command sets busy state in command result when busy", + function() + mock_device:set_field(lock_utils.ALIRO_READER_CONFIG_UPDATED, nil, {persist = true}) + lock_utils.is_busy_state_set(mock_device) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("lock.yml")})) + test.socket["matter"]:__expect_send({mock_device.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.lockAliro.commandResult( + { + commandName="setReaderConfig", + statusCode="busy" + }, + {state_change=true, visibility={displayed=false}} + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lockAlarm.supportedAlarmValues({"unableToLockTheDoor"}, {visibility = {displayed = false}})) + ) + end, + { + min_api_version = 15 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_battery.lua b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_battery.lua index 6926f1d62d..a13f3d455c 100644 --- a/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_battery.lua +++ b/drivers/SmartThings/matter-lock/src/test/test_new_matter_lock_battery.lua @@ -1,16 +1,5 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" test.set_rpc_version(0) @@ -18,7 +7,7 @@ local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" local t_utils = require "integration_test.utils" local uint32 = require "st.matter.data_types.Uint32" - +local cluster_base = require "st.matter.cluster_base" local DoorLock = clusters.DoorLock local mock_device = test.mock_device.build_test_matter_device({ @@ -173,13 +162,93 @@ local mock_device_user_pin_schedule_unlatch = test.mock_device.build_test_matter } }) +local mock_device_battery = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local mock_device_battery_level = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("lock-batteryLevel.yml"), + manufacturer_info = { + vendor_id = 0x147F, + product_id = 0x0001, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = DoorLock.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0x0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = 10 + }, + }, + device_types = { + { device_type_id = 0x000A, device_type_revision = 1 } -- Door Lock + } + } + } +}) + +local DoorLockFeatureMapAttr = {ID = 0xFFFC, cluster = DoorLock.ID} local function test_init() test.disable_startup_messages() -- subscribe request local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device) subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device)) + -- add test device, handle initial subscribe test.mock_device.add_test_device(mock_device) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -188,7 +257,6 @@ local function test_init() test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) @@ -200,8 +268,11 @@ local function test_init_unlatch() -- subscribe request local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_unlatch) subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_unlatch)) + subscribe_request:merge(cluster_base.subscribe(mock_device_unlatch, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_unlatch)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_unlatch)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_unlatch)) + -- add test device, handle initial subscribe test.mock_device.add_test_device(mock_device_unlatch) test.socket.matter:__expect_send({mock_device_unlatch.id, subscribe_request}) @@ -210,7 +281,6 @@ local function test_init_unlatch() test.socket.capability:__expect_send( mock_device_unlatch:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device_unlatch.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "init" }) test.socket.matter:__expect_send({mock_device_unlatch.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_unlatch.id, "doConfigure" }) @@ -227,9 +297,12 @@ local function test_init_user_pin() subscribe_request:merge(DoorLock.attributes.MaxPINCodeLength:subscribe(mock_device_user_pin)) subscribe_request:merge(DoorLock.attributes.MinPINCodeLength:subscribe(mock_device_user_pin)) subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_user_pin)) + subscribe_request:merge(cluster_base.subscribe(mock_device_user_pin, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_user_pin)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_user_pin)) subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_user_pin)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_user_pin)) + -- add test device, handle initial subscribe test.mock_device.add_test_device(mock_device_user_pin) test.socket.matter:__expect_send({mock_device_user_pin.id, subscribe_request}) @@ -238,7 +311,6 @@ local function test_init_user_pin() test.socket.capability:__expect_send( mock_device_user_pin:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device_user_pin.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "init" }) test.socket.matter:__expect_send({mock_device_user_pin.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin.id, "doConfigure" }) @@ -257,9 +329,12 @@ local function test_init_user_pin_schedule_unlatch() subscribe_request:merge(DoorLock.attributes.RequirePINforRemoteOperation:subscribe(mock_device_user_pin_schedule_unlatch)) subscribe_request:merge(DoorLock.attributes.NumberOfWeekDaySchedulesSupportedPerUser:subscribe(mock_device_user_pin_schedule_unlatch)) subscribe_request:merge(DoorLock.attributes.NumberOfYearDaySchedulesSupportedPerUser:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(cluster_base.subscribe(mock_device_user_pin_schedule_unlatch, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_user_pin_schedule_unlatch)) subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_user_pin_schedule_unlatch)) subscribe_request:merge(DoorLock.events.LockUserChange:subscribe(mock_device_user_pin_schedule_unlatch)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_user_pin_schedule_unlatch)) + -- add test device, handle initial subscribe test.mock_device.add_test_device(mock_device_user_pin_schedule_unlatch) test.socket.matter:__expect_send({mock_device_user_pin_schedule_unlatch.id, subscribe_request}) @@ -268,13 +343,62 @@ local function test_init_user_pin_schedule_unlatch() test.socket.capability:__expect_send( mock_device_user_pin_schedule_unlatch:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) ) - test.socket.matter:__expect_send({mock_device_user_pin_schedule_unlatch.id, clusters.PowerSource.attributes.AttributeList:read()}) test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "init" }) test.socket.matter:__expect_send({mock_device_user_pin_schedule_unlatch.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_user_pin_schedule_unlatch.id, "doConfigure" }) mock_device_user_pin_schedule_unlatch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end +local function test_init_battery() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_battery) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_battery)) + subscribe_request:merge(cluster_base.subscribe(mock_device_battery, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_battery)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_battery)) + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_device_battery)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_battery)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device_battery) + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + test.socket.capability:__expect_send( + mock_device_battery:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_battery_level() + test.disable_startup_messages() + -- subscribe request + local subscribe_request = DoorLock.attributes.LockState:subscribe(mock_device_battery_level) + subscribe_request:merge(DoorLock.attributes.OperatingMode:subscribe(mock_device_battery_level)) + subscribe_request:merge(cluster_base.subscribe(mock_device_battery_level, nil, DoorLockFeatureMapAttr.cluster, DoorLockFeatureMapAttr.ID)) + subscribe_request:merge(DoorLock.events.LockOperation:subscribe(mock_device_battery_level)) + subscribe_request:merge(DoorLock.events.DoorLockAlarm:subscribe(mock_device_battery_level)) + subscribe_request:merge(clusters.PowerSource.attributes.BatChargeLevel:subscribe(mock_device_battery_level)) + subscribe_request:merge(clusters.PowerSource.attributes.AttributeList:subscribe(mock_device_battery_level)) + + -- add test device, handle initial subscribe + test.mock_device.add_test_device(mock_device_battery_level) + test.socket.matter:__expect_send({mock_device_battery_level.id, subscribe_request}) + -- actual onboarding flow + test.socket.device_lifecycle:__queue_receive({ mock_device_battery_level.id, "added" }) + test.socket.capability:__expect_send( + mock_device_battery_level:generate_test_message("main", capabilities.lockAlarm.alarm.clear({state_change = true})) + ) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery_level.id, "init" }) + test.socket.matter:__expect_send({mock_device_battery_level.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery_level.id, "doConfigure" }) + mock_device_battery_level:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + test.set_test_init_function(test_init) test.register_coroutine_test( @@ -301,7 +425,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) ) mock_device:expect_metadata_update({ profile = "lock" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -329,7 +456,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) ) mock_device:expect_metadata_update({ profile = "lock-batteryLevel" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -358,7 +488,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.lock.supportedLockCommands({"lock", "unlock"}, {visibility = {displayed = false}})) ) mock_device:expect_metadata_update({ profile = "lock-battery" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -386,7 +519,10 @@ test.register_coroutine_test( ) mock_device_unlatch:expect_metadata_update({ profile = "lock-unlatch" }) end, - { test_init = test_init_unlatch } + { + test_init = test_init_unlatch, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -415,7 +551,10 @@ test.register_coroutine_test( ) mock_device_unlatch:expect_metadata_update({ profile = "lock-unlatch-batteryLevel" }) end, - { test_init = test_init_unlatch } + { + test_init = test_init_unlatch, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -445,7 +584,10 @@ test.register_coroutine_test( ) mock_device_unlatch:expect_metadata_update({ profile = "lock-unlatch-battery" }) end, - { test_init = test_init_unlatch } + { + test_init = test_init_unlatch, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -473,7 +615,10 @@ test.register_coroutine_test( ) mock_device_user_pin:expect_metadata_update({ profile = "lock-user-pin" }) end, - { test_init = test_init_user_pin } + { + test_init = test_init_user_pin, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -502,7 +647,10 @@ test.register_coroutine_test( ) mock_device_user_pin:expect_metadata_update({ profile = "lock-user-pin-batteryLevel" }) end, - { test_init = test_init_user_pin } + { + test_init = test_init_user_pin, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -532,7 +680,10 @@ test.register_coroutine_test( ) mock_device_user_pin:expect_metadata_update({ profile = "lock-user-pin-battery" }) end, - { test_init = test_init_user_pin } + { + test_init = test_init_user_pin, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -560,7 +711,10 @@ test.register_coroutine_test( ) mock_device_user_pin_schedule_unlatch:expect_metadata_update({ profile = "lock-user-pin-schedule-unlatch" }) end, - { test_init = test_init_user_pin_schedule_unlatch } + { + test_init = test_init_user_pin_schedule_unlatch, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -589,7 +743,10 @@ test.register_coroutine_test( ) mock_device_user_pin_schedule_unlatch:expect_metadata_update({ profile = "lock-user-pin-schedule-unlatch-batteryLevel" }) end, - { test_init = test_init_user_pin_schedule_unlatch } + { + test_init = test_init_user_pin_schedule_unlatch, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -619,7 +776,74 @@ test.register_coroutine_test( ) mock_device_user_pin_schedule_unlatch:expect_metadata_update({ profile = "lock-user-pin-schedule-unlatch-battery" }) end, - { test_init = test_init_user_pin_schedule_unlatch } + { + test_init = test_init_user_pin_schedule_unlatch, + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Handle received BatPercentRemaining from device.", + function() + test.socket.matter:__queue_receive( + { + mock_device_battery.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device_battery, 1, 150 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) + ) + end, + { + test_init = test_init_battery, + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Handle received BatChargeLevel from device.", + function() + test.socket.matter:__queue_receive( + { + mock_device_battery_level.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device_battery_level, 1, clusters.PowerSource.types.BatChargeLevelEnum.OK + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery_level:generate_test_message("main", capabilities.batteryLevel.battery.normal()) + ) + test.socket.matter:__queue_receive( + { + mock_device_battery_level.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device_battery_level, 1, clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery_level:generate_test_message("main", capabilities.batteryLevel.battery.critical()) + ) + test.socket.matter:__queue_receive( + { + mock_device_battery_level.id, + clusters.PowerSource.attributes.BatChargeLevel:build_test_report_data( + mock_device_battery_level, 1, clusters.PowerSource.types.BatChargeLevelEnum.WARNING + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery_level:generate_test_message("main", capabilities.batteryLevel.battery.warning()) + ) + end, + { + test_init = test_init_battery_level, + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/can_handle.lua b/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/can_handle.lua new file mode 100644 index 0000000000..3da6b948bf --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +local function is_unmigrated_matter_lock_product(opts, driver, device) + local is_migrated = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME) or nil + if device:supports_capability(capabilities.lockAlarm) then + return false + elseif device:supports_capability(capabilities.lockCodes) and is_migrated == true then + return false + else + return true, require("unmigrated-matter-lock") + end +end + +return is_unmigrated_matter_lock_product diff --git a/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/init.lua b/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/init.lua new file mode 100644 index 0000000000..6e7d55d895 --- /dev/null +++ b/drivers/SmartThings/matter-lock/src/unmigrated-matter-lock/init.lua @@ -0,0 +1,758 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local DoorLock = clusters.DoorLock +local PowerSource = clusters.PowerSource +local capabilities = require "st.capabilities" +local im = require "st.matter.interaction_model" +local lock_utils = require "lock_utils" + +local PROFILE_BASE_NAME = "__profile_base_name" +local INITIAL_COTA_INDEX = 1 +-- add this definition for locks to work on older lua libs +local UNLATCHED_STATE = 0x3 + +local subscribed_attributes = { + [capabilities.lock.ID] = { + DoorLock.attributes.LockState + }, + [capabilities.battery.ID] = { + PowerSource.attributes.BatPercentRemaining + }, + [capabilities.batteryLevel.ID] = { + PowerSource.attributes.BatChargeLevel + } +} + +--- If a device needs a cota credential this function attempts to set the credential +--- at the index provided. The set_credential_response_handler handles all failures +--- and retries with the appropriate index when necessary. +local function set_cota_credential(device, credential_index) + local eps = device:get_endpoints(DoorLock.ID) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred == nil then + -- Shouldn't happen but defensive to try to figure out if we need the cota cred and set it. + device:send(DoorLock.attributes.RequirePINforRemoteOperation:read(device, #eps > 0 and eps[1] or 1)) + return + elseif cota_cred == false then + device.log.debug("Device does not require PIN for remote operation. Not setting COTA credential") + return + end + + if device:get_field(lock_utils.SET_CREDENTIAL) ~= nil then + device.log.debug("delaying setting COTA credential since a credential is currently being set") + device.thread:call_with_delay(2, function(t) + set_cota_credential(device, credential_index) + end) + return + end + + device:set_field(lock_utils.COTA_CRED_INDEX, credential_index, {persist = true}) + local credential = {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = credential_index} + -- Set the credential to a code + device:set_field(lock_utils.SET_CREDENTIAL, credential_index) + device.log.info(string.format("Attempting to set COTA credential at index %s", credential_index)) + device:send(DoorLock.server.commands.SetCredential( + device, + #eps > 0 and eps[1] or 1, + DoorLock.types.DlDataOperationType.ADD, + credential, + device:get_field(lock_utils.COTA_CRED), + nil, -- nil user_index creates a new user + DoorLock.types.DlUserStatus.OCCUPIED_ENABLED, + DoorLock.types.DlUserType.REMOTE_ONLY_USER + )) +end + +local function generate_cota_cred_for_device(device) + local len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) or 4 + local cred_data = math.floor(math.random() * (10 ^ len)) + cred_data = string.format("%0" .. tostring(len) .. "d", cred_data) + device:set_field(lock_utils.COTA_CRED, cred_data, {persist = true}) +end + +local function lock_state_handler(driver, device, ib, response) + local LockState = DoorLock.attributes.LockState + local attr = capabilities.lock.lock + local LOCK_STATE = { + [LockState.NOT_FULLY_LOCKED] = attr.not_fully_locked(), + [LockState.LOCKED] = attr.locked(), + [LockState.UNLOCKED] = attr.unlocked(), + [UNLATCHED_STATE] = attr.unlocked(), -- Fully unlocked with latch pulled + } + + if ib.data.value ~= nil then + local event = LOCK_STATE[ib.data.value] + if event ~= nil then + device:emit_event(event) + else + device.log.warn(string.format("Received unknown lock state value (%s), emitting unknown", ib.data.value)) + device:emit_event(attr.unknown()) + end + else + device:emit_event(LOCK_STATE[LockState.NOT_FULLY_LOCKED]) + end +end + +local function handle_battery_percent_remaining(driver, device, ib, response) + if ib.data.value ~= nil then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +local function handle_battery_charge_level(driver, device, ib, response) + if ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.OK then + device:emit_event(capabilities.batteryLevel.battery.normal()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.WARNING then + device:emit_event(capabilities.batteryLevel.battery.warning()) + elseif ib.data.value == clusters.PowerSource.types.BatChargeLevelEnum.CRITICAL then + device:emit_event(capabilities.batteryLevel.battery.critical()) + end +end + +local function handle_power_source_attribute_list(driver, device, ib, response) + local support_battery_percentage = false + local support_battery_level = false + for _, attr in ipairs(ib.data.elements) do + -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or + -- BatChargeLevel (Attribute ID 0x0E) is present. + if attr.value == 0x0C then + support_battery_percentage = true + elseif attr.value == 0x0E then + support_battery_level = true + end + end + local profile_name = device:get_field(PROFILE_BASE_NAME) + if profile_name ~= nil then + if not support_battery_percentage then + if support_battery_level then + profile_name = profile_name .. "-batteryLevel" + else + profile_name = profile_name .. "-nobattery" + end + end + device.log.info(string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + end +end + +local function max_pin_code_len_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCodes.maxCodeLength(ib.data.value, {visibility = {displayed = false}})) +end + +local function min_pin_code_len_handler(driver, device, ib, response) + device:emit_event(capabilities.lockCodes.minCodeLength(ib.data.value, {visibility = {displayed = false}})) +end + +local function num_pin_users_handler(driver, device, ib, response) + device:set_field(lock_utils.TOTAL_PIN_USERS, ib.data.value) + device:emit_event(capabilities.lockCodes.maxCodes(ib.data.value, {visibility = {displayed = false}})) +end + +local function apply_cota_credentials_if_absent(device) + if not device:get_field(lock_utils.COTA_CRED) then + --Process after all other info blocks have been dispatched to ensure MaxPINCodeLength has been processed + device.thread:call_with_delay(0, function(t) + generate_cota_cred_for_device(device) + -- delay needed to allow test to override the random credential data + device.thread:call_with_delay(0, function(t) + -- Attempt to set cota credential at the lowest index + set_cota_credential(device, INITIAL_COTA_INDEX) + end) + end) + end +end + +local function require_remote_pin_handler(driver, device, ib, response) + if ib.data.value then + apply_cota_credentials_if_absent(device) + else + device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + end +end + +local function clear_credential_response_handler(driver, device, ib, response) + local deleted_code_slot = device:get_field(lock_utils.DELETING_CODE) + if deleted_code_slot == nil and ib.status == im.InteractionResponse.Status.SUCCESS then + device.log.debug("Cleared space in lock credential db for COTA credential") + return + end + if ib.status == im.InteractionResponse.Status.SUCCESS then + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, tostring(deleted_code_slot))) + --make sure cota credential exists if the user deletes it or if space was created for the COTA cred + if deleted_code_slot == device:get_field(lock_utils.COTA_CRED_INDEX) or + device:get_field(lock_utils.NONFUNCTIONAL) then + set_cota_credential(device, device:get_field(lock_utils.COTA_CRED_INDEX) or INITIAL_COTA_INDEX) + end + else + device.log.error(string.format("Failed to delete code slot %s", deleted_code_slot)) + end + device:set_field(lock_utils.DELETING_CODE, nil) +end + +local function set_credential_response_handler(driver, device, ib, response) + if ib.status ~= im.InteractionResponse.Status.SUCCESS then + device.log.error("Failed to set code for device") + return + end + local elements = ib.info_block.data.elements + local credential_index = device:get_field(lock_utils.SET_CREDENTIAL) + device:set_field(lock_utils.SET_CREDENTIAL, nil) + if credential_index == nil then + device.log.error("Received unexpected SetCredentialResponse") + return + end + local code_slot = tostring(credential_index) + local status = elements.status.value + if status == DoorLock.types.DlStatus.SUCCESS then + local event = capabilities.lockCodes.codeChanged("", {state_change = true}) + local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) + local code_name = (credential_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or + lock_utils.get_code_name(device, code_slot) + event.data = {codeName = code_name} + event.value = lock_utils.get_change_type(device, tostring(code_slot)) + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = event.data.codeName + device:emit_event(event) + if credential_index == cota_cred_index then + device:emit_event( + capabilities.lockCodes.codeChanged( + code_slot .. " renamed", {state_change = true} + ) + ) + end + lock_utils.lock_codes_event(device, lock_codes) + lock_utils.reset_code_state(device, code_slot) + if device:get_field(lock_utils.NONFUNCTIONAL) and cota_cred_index == credential_index then + device.log.info("Successfully set COTA credential after being non-functional") + device:set_field(lock_utils.NONFUNCTIONAL, false, {persist = true}) + local profile_name = "base-lock" + device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps == 0 then + profile_name = profile_name .. "-nobattery" + device.log.info(string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name, provisioning_state = "PROVISIONED"}) + else + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + req:merge(clusters.PowerSource.attributes.AttributeList:read()) + device:send(req) + end + end + elseif device:get_field(lock_utils.COTA_CRED) and credential_index == device:get_field(lock_utils.COTA_CRED_INDEX) then + -- Handle failure to set a COTA credential + if status == DoorLock.types.DlStatus.OCCUPIED and elements.next_credential_index.value ~= nil then + --This credential index is unavailable, but there is another available + set_cota_credential(device, elements.next_credential_index.value) + elseif status == DoorLock.types.DlStatus.OCCUPIED and + elements.next_credential_index.value == nil and + credential_index == INITIAL_COTA_INDEX then + --There are no credential indices available on the device + device.log.error("Device requires COTA credential, but has no credential indexes available!") + device.log.error("Lock and Unlock commands will no longer work!!") + device:set_field(PROFILE_BASE_NAME, "nonfunctional-lock", {persist = true}) + device:try_update_metadata({profile = "nonfunctional-lock", provisioning_state = "NONFUNCTIONAL"}) + device:set_field(lock_utils.NONFUNCTIONAL, true, {persist = true}) + elseif status == DoorLock.types.DlStatus.OCCUPIED and elements.next_credential_index.value == nil then + --There are no credential indices available, but we must ensure we search all indices. + set_cota_credential(device, INITIAL_COTA_INDEX) + elseif status == DoorLock.types.DlStatus.DUPLICATE then + --The credential we randomly generated already exists + generate_cota_cred_for_device(device) + --delay 0 needed for unit test verification of random value + device.thread:call_with_delay(0, function(t) set_cota_credential(device, credential_index) end) + elseif status == DoorLock.types.DlStatus.INVALID_FIELD then + device.log.error("Invalid SetCredential command sent to set a COTA credential. This is a bug.") + elseif elements.next_credential_index.value ~= nil then + device.log.warn(string.format( + "Received non-success SetCredentialResponse status (%s), but there is a next credential index available", elements.status + )) + set_cota_credential(device, elements.next_credential_index.value) + end + else + device.log.error( + string.format( + "Failed to set user code for device, SetCredential status received: %s", elements.status + ) + ) + end +end + +local function get_credential_status_response_handler(driver, device, ib, response) + if ib.status ~= im.InteractionResponse.Status.SUCCESS then + device.log.warn("Not taking action on GetCredentialStatusResponse because failed status") + end + local cred_index = device:get_field(lock_utils.CHECKING_CREDENTIAL) + if cred_index == nil then + device.log.warn("Received unexpected CredentialStatusResponse") + return + end + local elements = ib.info_block.data.elements + local credential_exists = elements.credential_exists.value + local next_credential_index = elements.next_credential_index and elements.next_credential_index.value or nil + + local event = capabilities.lockCodes.codeChanged("", {state_change = true}) + local code_slot = tostring(cred_index) + local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) + local code_name = (cred_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or lock_utils.get_code_name(device, code_slot) + event.data = {codeName = code_name} + if credential_exists then + -- Code slot is occupied + event.value = lock_utils.get_change_type(device, code_slot) + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = event.data.codeName + device:emit_event(event) + lock_utils.lock_codes_event(device, lock_codes) + lock_utils.reset_code_state(device, code_slot) + else + -- Code slot is unoccupied + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + -- Code has been deleted + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + if cred_index == cota_cred_index then --make sure cota credential exists if it was deleted + set_cota_credential(device, INITIAL_COTA_INDEX) + end + else + -- Code is unset + event.value = code_slot .. " unset" + device:emit_event(event) + end + end + device:set_field(lock_utils.CHECKING_CREDENTIAL, nil) + + local is_scanning = device:get_latest_state( + "main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME + ) == "Scanning" + if not is_scanning then + return + end + if (next_credential_index == nil) then + device:emit_event( + capabilities.lockCodes.scanCodes( + "Complete", {visibility = {displayed = false}} + ) + ) + local lock_codes = lock_utils.get_lock_codes(device) + lock_utils.lock_codes_event(device, lock_codes) + elseif next_credential_index ~= nil then + device:set_field(lock_utils.CHECKING_CREDENTIAL, next_credential_index) + device:send( + DoorLock.server.commands.GetCredentialStatus( + device, + ib.info_block.endpoint_id, + {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = device:get_field(lock_utils.CHECKING_CREDENTIAL)} + ) + ) + end +end + +local function alarm_event_handler(driver, device, ib, response) + local DlAlarmCode = DoorLock.types.DlAlarmCode + local alarm_code = ib.data.elements.alarm_code + if alarm_code.value == DlAlarmCode.FRONT_ESCEUTCHEON_REMOVED or alarm_code.value + == DlAlarmCode.WRONG_CODE_ENTRY_LIMIT or alarm_code.value == DlAlarmCode.FORCED_USER + or alarm_code.value == DlAlarmCode.DOOR_FORCED_OPEN then + device:emit_event(capabilities.tamperAlert.tamper.detected()) + end +end + +local function lock_op_event_handler(driver, device, ib, response) + local tamper_detected = device:get_latest_state( + device:endpoint_to_component(ib.endpoint_id), + capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME + ) + if nil == tamper_detected or tamper_detected == capabilities.tamperAlert.tamper.detected.NAME then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function lock_user_change_event_handler(driver, device, ib, response) + local event = capabilities.lockCodes.codeChanged("", {state_change = true}) + local elements = ib.data.elements + local data_type_changed = elements.lock_data_type.value + local operation_type = elements.data_operation_type.value + local user_index = elements.user_index.value + local data_index = elements.data_index and elements.data_index.value + local cota_cred_index = device:get_field(lock_utils.COTA_CRED_INDEX) + + if data_type_changed == DoorLock.types.DlLockDataType.PIN then -- pin added or removed + local code_slot = data_index and tostring(data_index) or nil + if (operation_type == DoorLock.types.DlDataOperationType.ADD or operation_type + == DoorLock.types.DlDataOperationType.MODIFY) and code_slot ~= nil then + local change_type = lock_utils.get_change_type(device, code_slot) + event.value = change_type + local code_name = (data_index == cota_cred_index and lock_utils.COTA_CODE_NAME) or lock_utils.get_code_name(device, code_slot) + event.data = {codeName = code_name} + device:emit_event(event) + if string.match(change_type, "%d+ set") ~= nil then + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = code_name + lock_utils.lock_codes_event(device, lock_codes) + end + elseif operation_type == DoorLock.types.DlDataOperationType.CLEAR and code_slot ~= nil then + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, tostring(code_slot))) + --make sure cota credential is created if the user deletes it or a space is made for it + if data_index == cota_cred_index or device:get_field(lock_utils.NONFUNCTIONAL) then + set_cota_credential(device, cota_cred_index or INITIAL_COTA_INDEX) + end + else -- invalid event because no credential index + device.log.error( + "Received unhandled LockUserChangeEvent because it didn't affect a PIN credential" + ) + end + elseif data_type_changed == DoorLock.types.DlLockDataType.USER_INDEX and operation_type + == DoorLock.types.DlDataOperationType.CLEAR then + if user_index == 0xFFFE then + device.log.warn("All users were cleared by another fabric") -- we never do this + for cs, _ in pairs(lock_utils.get_lock_codes(device)) do + lock_utils.code_deleted(device, cs) + end + lock_utils.lock_codes_event(device, {}) + if device:get_field(lock_utils.COTA_CRED) ~= nil then set_cota_credential(device, INITIAL_COTA_INDEX) end + else + device.log.info("Not handling LockUserChange event") + end + -- Note when a Lock User is deleted, the credentials associated with that user are also deleted. + -- Change events are created for each credential as well as the user. + else + device.log.info( + string.format( + "Not handling LockUserChange event because the data type (%s) doesn't affect lock codes", + elements.lock_data_type + ) + ) + end +end + +local function handle_refresh(driver, device, command) + -- Note: no endpoint specified indicates a wildcard endpoint + local req = DoorLock.attributes.LockState:read(device) + req:merge(PowerSource.attributes.BatPercentRemaining:read(device)) + device:send(req) +end + +local function handle_lock(driver, device, command) + local ep = device:component_to_endpoint(command.component) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred then + device:send( + DoorLock.server.commands.LockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.LockDoor(device, ep)) + end +end + +local function handle_unlock(driver, device, command) + local ep = device:component_to_endpoint(command.component) + local cota_cred = device:get_field(lock_utils.COTA_CRED) + if cota_cred then + device:send( + DoorLock.server.commands.UnlockDoor(device, ep, cota_cred) + ) + else + device:send(DoorLock.server.commands.UnlockDoor(device, ep)) + end +end + +local function handle_delete_code(driver, device, command) + local endpoint = device:component_to_endpoint(command.component) + device:set_field(lock_utils.DELETING_CODE, command.args.codeSlot) + device:send(DoorLock.server.commands.ClearCredential( + device, + endpoint, + {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = command.args.codeSlot} + )) +end + +local function handle_reload_all_codes(driver, device, command) + if (device:get_field(lock_utils.CHECKING_CREDENTIAL) == nil) then + lock_utils.lock_codes_event(device, {}) + device:set_field(lock_utils.CHECKING_CREDENTIAL, 1) + else + device.log.info(string.format("Delaying scanning since currently checking credential %d", device:get_field(lock_utils.CHECKING_CREDENTIAL))) + device.thread:call_with_delay(2, function(t) handle_reload_all_codes(driver, device, command) end) + return + end + device.thread:call_with_delay(5, function(t) + device:emit_event(capabilities.lockCodes.scanCodes("Scanning")) + device:send( + clusters.DoorLock.server.commands.GetCredentialStatus( + device, device:component_to_endpoint(command.component), + {credential_type = DoorLock.types.DlCredentialType.PIN, credential_index = device:get_field(lock_utils.CHECKING_CREDENTIAL)} + ) + ) + end) +end + +local function handle_request_code(driver, device, command) + local endpoint_id = device:component_to_endpoint(command.component) + device:set_field(lock_utils.CHECKING_CREDENTIAL, command.args.codeSlot) + local credential = { + credential_type = DoorLock.types.DlCredentialType.PIN, + credential_index = command.args.codeSlot, + } + device:send(clusters.DoorLock.server.commands.GetCredentialStatus(device, endpoint_id, credential)) +end + +local function handle_set_code(driver, device, command) + local endpoint = device:component_to_endpoint(command.component) + if (command.args.codePIN == "") then + driver:inject_capability_command( + device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = {command.args.codeSlot, command.args.codeName}, + } + ) + else + local credential = { + credential_type = DoorLock.types.DlCredentialType.PIN, + credential_index = command.args.codeSlot, + } + device:set_field(lock_utils.SET_CREDENTIAL, command.args.codeSlot) + device:send( + DoorLock.server.commands.SetCredential( + device, endpoint, DoorLock.types.DlDataOperationType.ADD, -- operation_type + credential, command.args.codePIN, -- credential_data + nil, -- nil user_index creates a new user + DoorLock.types.DlUserStatus.OCCUPIED_ENABLED, DoorLock.types.DlUserType.UNRESTRICTED_USER + ) + ) + if (command.args.codeName ~= nil) then + -- wait for confirmation from the lock to commit this to memory + -- Groovy driver has a lot more info passed here as a description string, may need to be investigated + local codeState = device:get_field(lock_utils.CODE_STATE) or {} + codeState["setName" .. command.args.codeSlot] = command.args.codeName + device:set_field(lock_utils.CODE_STATE, codeState, {persist = true}) + end + end +end + +local function handle_name_slot(driver, device, command) + local code_slot = tostring(command.args.codeSlot) + local lock_codes = lock_utils.get_lock_codes(device) + if (lock_codes[code_slot] ~= nil) then + lock_codes[code_slot] = command.args.codeName + device:emit_event( + capabilities.lockCodes.codeChanged( + code_slot .. " renamed", {state_change = true} + ) + ) + lock_utils.lock_codes_event(device, lock_codes) + end +end + +local function json_to_table(input) + if #input == 0 then return end + local result = {} + for key, value in string.gmatch(input, '"(.-)"%s*:%s*"(.-)"') do + table.insert(result, {key, value}) + end + return result +end + +local function store_lock_codes(device) + local lockCodes = device:get_latest_state( + "main", + capabilities.lockCodes.ID, + capabilities.lockCodes.lockCodes.NAME, + {} + ) + lockCodes = json_to_table(lockCodes) + device:set_field(lock_utils.LOCK_CODES_FOR_MIGRATION, lockCodes, {persist = true}) +end + +local function handle_migrate(driver, device, command) + store_lock_codes(device) + device:set_field(lock_utils.LOCK_CODES_COPY_REQUIRED, true, {persist = true}) + device:emit_event(capabilities.lockCodes.migrated(true)) + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) +end + +local function find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = device:get_endpoints(cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + return v + end + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return res +end + +local function component_to_endpoint(device, component_name) + return find_default_endpoint(device, clusters.DoorLock.ID) +end + +local function info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + device:subscribe() + end +end + +local function do_configure(driver, device) + -- check if the device is NOT currently profiled as base-lock + -- by ANDing a query for every capability in the base-lock profiles. + -- If it does not use base-lock, it is WWST and does not need re-profiling. + if not (device:supports_capability(capabilities.lock) and + device:supports_capability(capabilities.lockCodes) and + device:supports_capability(capabilities.tamperAlert) and + device:supports_capability(capabilities.battery)) then + return + end + + -- if not fingerprinted, dynamically configure base-lock profile + local profile_name = "base-lock" + device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps == 0 then + profile_name = profile_name .. "-nobattery" + device.log.info(string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + else + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + req:merge(clusters.PowerSource.attributes.AttributeList:read()) + device:send(req) + end +end + +local function device_init(driver, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:subscribe() + + -- check if we have a missing COTA credential. Only run this if it has not been run before (i.e. in device added), + -- because there is a delay built into the COTA process and we do not want to start two COTA generations at the same time + -- in the event this was triggered on add. + if not device:get_field(lock_utils.COTA_READ_INITIALIZED) or not device:get_field(lock_utils.COTA_CRED) then + local eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.CREDENTIALSOTA | DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) + if #eps == 0 then + device.log.debug("Device will not require PIN for remote operation") + device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + else + device:send(DoorLock.attributes.RequirePINforRemoteOperation:read(device, eps[1])) + device:set_field(lock_utils.COTA_READ_INITIALIZED, true, {persist = true}) + end + end +end + +local function device_added(driver, device) + --Note: May want to write OperatingMode to NORMAL, to attempt to ensure remote operation works + --Note: May want to write RequirePINForRemoteOperation, to avoid cota cases if possible. + device:emit_event(capabilities.tamperAlert.tamper.clear()) + local eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) + if #eps == 0 then + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device.log.debug("Device does not support lockCodes. Switching profile.") + local profile_name = "lock-without-codes" + device:set_field(PROFILE_BASE_NAME, profile_name, {persist = true}) + local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) + if #battery_feature_eps == 0 then + profile_name = profile_name .. "-nobattery" + end + device.log.info(string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + else + device.log.debug("Device supports neither lock codes nor tamper. Unable to switch profile.") + end + else + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + req:merge(DoorLock.attributes.MaxPINCodeLength:read(device, eps[1])) + req:merge(DoorLock.attributes.MinPINCodeLength:read(device, eps[1])) + req:merge(DoorLock.attributes.NumberOfPINUsersSupported:read(device, eps[1])) + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + + --Device may require pin for remote operation if it supports COTA and PIN features. + eps = device:get_endpoints(DoorLock.ID, {feature_bitmap = DoorLock.types.DoorLockFeature.CREDENTIALSOTA | DoorLock.types.DoorLockFeature.PIN_CREDENTIALS}) + if #eps == 0 then + device.log.debug("Device will not require PIN for remote operation") + device:set_field(lock_utils.COTA_CRED, false, {persist = true}) + else + req:merge(DoorLock.attributes.RequirePINforRemoteOperation:read(device, eps[1])) + device:set_field(lock_utils.COTA_READ_INITIALIZED, true, {persist = true}) + end + device:send(req) + end +end + +local unmigrated_matter_lock_driver = { + NAME = "Unmigrated Matter Lock Driver", + matter_handlers = { + attr = { + [DoorLock.ID] = { + [DoorLock.attributes.LockState.ID] = lock_state_handler, + [DoorLock.attributes.MaxPINCodeLength.ID] = max_pin_code_len_handler, + [DoorLock.attributes.MinPINCodeLength.ID] = min_pin_code_len_handler, + [DoorLock.attributes.NumberOfPINUsersSupported.ID] = num_pin_users_handler, + [DoorLock.attributes.RequirePINforRemoteOperation.ID] = require_remote_pin_handler, + }, + [PowerSource.ID] = { + [PowerSource.attributes.AttributeList.ID] = handle_power_source_attribute_list, + [PowerSource.attributes.BatPercentRemaining.ID] = handle_battery_percent_remaining, + [PowerSource.attributes.BatChargeLevel.ID] = handle_battery_charge_level, + }, + }, + event = { + [DoorLock.ID] = { + [DoorLock.events.DoorLockAlarm.ID] = alarm_event_handler, + [DoorLock.events.LockOperation.ID] = lock_op_event_handler, + [DoorLock.events.LockUserChange.ID] = lock_user_change_event_handler, + }, + }, + cmd_response = { + [DoorLock.ID] = { + [DoorLock.client.commands.SetCredentialResponse.ID] = set_credential_response_handler, + [DoorLock.client.commands.GetCredentialStatusResponse.ID] = get_credential_status_response_handler, + [DoorLock.server.commands.ClearCredential.ID] = clear_credential_response_handler, + }, + }, + }, + subscribed_attributes = subscribed_attributes, + subscribed_events = { + [capabilities.tamperAlert.ID] = {DoorLock.events.DoorLockAlarm, DoorLock.events.LockOperation}, + [capabilities.lockAlarm.ID] = {DoorLock.events.DoorLockAlarm}, + [capabilities.lockCodes.ID] = {DoorLock.events.LockUserChange}, + }, + capability_handlers = { + [capabilities.refresh.ID] = {[capabilities.refresh.commands.refresh.NAME] = handle_refresh}, + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = handle_lock, + [capabilities.lock.commands.unlock.NAME] = handle_unlock, + }, + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.deleteCode.NAME] = handle_delete_code, + [capabilities.lockCodes.commands.reloadAllCodes.NAME] = handle_reload_all_codes, + [capabilities.lockCodes.commands.requestCode.NAME] = handle_request_code, + [capabilities.lockCodes.commands.setCode.NAME] = handle_set_code, + [capabilities.lockCodes.commands.nameSlot.NAME] = handle_name_slot, + [capabilities.lockCodes.commands.migrate.NAME] = handle_migrate, + }, + }, + supported_capabilities = { + capabilities.lock, + capabilities.lockCodes, + capabilities.tamperAlert, + capabilities.battery, + capabilities.batteryLevel, + }, + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + infoChanged = info_changed, + }, + can_handle = require("unmigrated-matter-lock.can_handle"), +} + +return unmigrated_matter_lock_driver diff --git a/drivers/SmartThings/matter-media/src/test/test_matter_media_speaker.lua b/drivers/SmartThings/matter-media/src/test/test_matter_media_speaker.lua index cf5bc44648..85d8d8868b 100644 --- a/drivers/SmartThings/matter-media/src/test/test_matter_media_speaker.lua +++ b/drivers/SmartThings/matter-media/src/test/test_matter_media_speaker.lua @@ -124,6 +124,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.audioMute.mute.muted()) } + }, + { + min_api_version = 15 } ) @@ -162,6 +165,9 @@ test.register_message_test( clusters.OnOff.server.commands.On(mock_device, 10) } } + }, + { + min_api_version = 15 } ) @@ -205,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(20)) } + }, + { + min_api_version = 15 } ) @@ -324,6 +333,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.audioVolume.volume(20)) }, + }, + { + min_api_version = 15 } ) @@ -352,6 +364,9 @@ test.register_message_test( refresh_commands(mock_device) } }, + }, + { + min_api_version = 15 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-media/src/test/test_matter_media_video_player.lua b/drivers/SmartThings/matter-media/src/test/test_matter_media_video_player.lua index ebae93ca53..c94b63275a 100644 --- a/drivers/SmartThings/matter-media/src/test/test_matter_media_video_player.lua +++ b/drivers/SmartThings/matter-media/src/test/test_matter_media_video_player.lua @@ -238,6 +238,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) }, + }, + { + min_api_version = 15 } ) @@ -273,6 +276,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.mediaPlayback.playbackStatus.playing()) }, + }, + { + min_api_version = 15 } ) @@ -308,6 +314,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.mediaPlayback.playbackStatus.paused()) }, + }, + { + min_api_version = 15 } ) @@ -343,6 +352,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.mediaPlayback.playbackStatus.stopped()) }, + }, + { + min_api_version = 15 } ) @@ -381,6 +393,9 @@ test.register_message_test( clusters.MediaPlayback.server.commands.FastForward(mock_device, 10) } }, + }, + { + min_api_version = 15 } ) @@ -419,6 +434,9 @@ test.register_message_test( clusters.MediaPlayback.server.commands.Next(mock_device, 10) } }, + }, + { + min_api_version = 15 } ) @@ -585,6 +603,9 @@ test.register_message_test( clusters.KeypadInput.server.commands.SendKey(mock_device, 10, clusters.KeypadInput.types.CecKeyCode.ROOT_MENU) } } + }, + { + min_api_version = 15 } ) @@ -615,7 +636,10 @@ test.register_coroutine_test( ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -645,7 +669,10 @@ test.register_coroutine_test( ) mock_device_variable_speed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-pump/src/init.lua b/drivers/SmartThings/matter-pump/src/init.lua index db43d3b1df..0e79f66cbb 100644 --- a/drivers/SmartThings/matter-pump/src/init.lua +++ b/drivers/SmartThings/matter-pump/src/init.lua @@ -1,328 +1,329 @@ --- Copyright 2024 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. - -local capabilities = require "st.capabilities" -local log = require "log" -local clusters = require "st.matter.clusters" -local embedded_cluster_utils = require "embedded-cluster-utils" -local MatterDriver = require "st.matter.driver" - -local IS_LOCAL_OVERRIDE = "__is_local_override" --- Per matter spec, the pump level is in steps of 0.5% and the --- max level value is 200. Anything above is considered 100% -local MAX_PUMP_ATTR_LEVEL = 200 -local MAX_CAP_SWITCH_LEVEL = 100 - --- Include driver-side definitions when lua libs api version is < 10 -local version = require "version" -if version.api < 10 then - clusters.PumpConfigurationAndControl = require "PumpConfigurationAndControl" -end - -local pumpOperationMode = capabilities.pumpOperationMode -local pumpControlMode = capabilities.pumpControlMode - -local PUMP_OPERATION_MODE_MAP = { - [clusters.PumpConfigurationAndControl.types.OperationModeEnum.NORMAL] = pumpOperationMode.operationMode.normal, - [clusters.PumpConfigurationAndControl.types.OperationModeEnum.MINIMUM] = pumpOperationMode.operationMode.minimum, - [clusters.PumpConfigurationAndControl.types.OperationModeEnum.MAXIMUM] = pumpOperationMode.operationMode.maximum, - [clusters.PumpConfigurationAndControl.types.OperationModeEnum.LOCAL] = pumpOperationMode.operationMode.localSetting, -} - -local PUMP_CONTROL_MODE_MAP = { - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_SPEED] = pumpControlMode.controlMode.constantSpeed, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_PRESSURE] = pumpControlMode.controlMode.constantPressure, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.PROPORTIONAL_PRESSURE] = pumpControlMode.controlMode.proportionalPressure, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_FLOW] = pumpControlMode.controlMode.constantFlow, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_TEMPERATURE] = pumpControlMode.controlMode.constantTemperature, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.AUTOMATIC] = pumpControlMode.controlMode.automatic, -} - -local PUMP_CURRENT_CONTROL_MODE_MAP = { - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_SPEED] = pumpControlMode.currentControlMode.constantSpeed, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_PRESSURE] = pumpControlMode.currentControlMode.constantPressure, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.PROPORTIONAL_PRESSURE] = pumpControlMode.currentControlMode.proportionalPressure, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_FLOW] = pumpControlMode.currentControlMode.constantFlow, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_TEMPERATURE] = pumpControlMode.currentControlMode.constantTemperature, - [clusters.PumpConfigurationAndControl.types.ControlModeEnum.AUTOMATIC] = pumpControlMode.currentControlMode.automatic, -} - -local subscribed_attributes = { - [capabilities.switch.ID] = { - clusters.OnOff.attributes.OnOff, - }, - [capabilities.switchLevel.ID] = { - clusters.LevelControl.attributes.CurrentLevel - }, - [capabilities.pumpOperationMode.ID]={ - clusters.PumpConfigurationAndControl.attributes.OperationMode, - clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode, - clusters.PumpConfigurationAndControl.attributes.PumpStatus, - }, - [capabilities.pumpControlMode.ID]={ - clusters.PumpConfigurationAndControl.attributes.EffectiveControlMode, - }, -} - -local function find_default_endpoint(device, cluster) - local res = device.MATTER_DEFAULT_ENDPOINT - local eps = embedded_cluster_utils.get_endpoints(device, cluster) - table.sort(eps) - for _, v in ipairs(eps) do - if v ~= 0 then --0 is the matter RootNode endpoint - return v - end - end - device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) - return res -end - -local function component_to_endpoint(device, component_name) - -- Use the find_default_endpoint function to return the first endpoint that - -- supports a given cluster. - return find_default_endpoint(device, clusters.PumpConfigurationAndControl.ID) -end - -local function device_init(driver, device) - device:subscribe() - device:set_component_to_endpoint_fn(component_to_endpoint) -end - -local function info_changed(driver, device, event, args) - --Note this is needed because device:subscribe() does not recalculate - -- the subscribed attributes each time it is run, that only happens at init. - -- This will change in the 0.48.x release of the lua libs. - for cap_id, attributes in pairs(subscribed_attributes) do - if device:supports_capability_by_id(cap_id) then - for _, attr in ipairs(attributes) do - device:add_subscribed_attribute(attr) - end - end - end - device:subscribe() -end - -local function set_supported_op_mode(driver, device) - local spd_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_SPEED}) - local local_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.LOCAL_OPERATION}) - local supported_op_modes = {pumpOperationMode.operationMode.normal.NAME} - if #spd_eps > 0 then - table.insert(supported_op_modes, pumpOperationMode.operationMode.minimum.NAME) - table.insert(supported_op_modes, pumpOperationMode.operationMode.maximum.NAME) - end - if #local_eps > 0 then - table.insert(supported_op_modes, pumpOperationMode.operationMode.localSetting.NAME) - end - device:emit_event(pumpOperationMode.supportedOperationModes(supported_op_modes)) -end - -local function set_supported_control_mode(driver, device) - local spd_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_SPEED}) - local prsconst_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_PRESSURE}) - local prscomp_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.COMPENSATED_PRESSURE}) - local flw_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_FLOW}) - local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_TEMPERATURE}) - local auto_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.AUTOMATIC}) - local supported_control_modes = {} - if #spd_eps > 0 then - table.insert(supported_control_modes, pumpControlMode.controlMode.constantSpeed.NAME) - end - if #prsconst_eps > 0 then - table.insert(supported_control_modes, pumpControlMode.controlMode.constantPressure.NAME) - end - if #prscomp_eps > 0 then - table.insert(supported_control_modes, pumpControlMode.controlMode.proportionalPressure.NAME) - end - if #flw_eps > 0 then - table.insert(supported_control_modes, pumpControlMode.controlMode.constantFlow.NAME) - end - if #temp_eps > 0 then - table.insert(supported_control_modes, pumpControlMode.controlMode.constantTemperature.NAME) - end - if #auto_eps > 0 then - table.insert(supported_control_modes, pumpControlMode.controlMode.automatic.NAME) - end - device:emit_event(pumpControlMode.supportedControlModes(supported_control_modes)) -end - -local function do_configure(driver, device) - local pump_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID) - local level_eps = embedded_cluster_utils.get_endpoints(device, clusters.LevelControl.ID) - local profile_name = "pump" - if #pump_eps == 1 then - if #level_eps > 0 then - profile_name = profile_name .. "-level" - else - profile_name = profile_name .. "-only" - end - device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) - device:try_update_metadata({profile = profile_name}) - else - device.log.warn_with({hub_logs=true}, "Device does not support pump configuration and control cluster") - end - set_supported_op_mode(driver, device) - set_supported_control_mode(driver, device) -end - --- Matter Handlers -- -local function on_off_attr_handler(driver, device, ib, response) - if ib.data.value then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) - end -end - -local function level_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - local level = math.floor((ib.data.value / MAX_PUMP_ATTR_LEVEL * MAX_CAP_SWITCH_LEVEL) + 0.5) - level = math.min(level, MAX_CAP_SWITCH_LEVEL) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.level(level)) - end -end - -local function effective_operation_mode_handler(driver, device, ib, response) - local modeEnum = clusters.PumpConfigurationAndControl.types.OperationModeEnum - local supported_control_modes = {} - local local_override = device:get_field(IS_LOCAL_OVERRIDE) - if not local_override then - set_supported_op_mode(driver, device) - end - if ib.data.value == modeEnum.NORMAL then - device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.normal()) - set_supported_control_mode(driver, device) - elseif ib.data.value == modeEnum.MINIMUM then - device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.minimum()) - device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) - elseif ib.data.value == modeEnum.MAXIMUM then - device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.maximum()) - device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) - elseif ib.data.value == modeEnum.LOCAL then - device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.localSetting()) - device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) - end -end - -local function effective_control_mode_handler(driver, device, ib, response) - device:emit_event_for_endpoint(ib.endpoint_id, PUMP_CURRENT_CONTROL_MODE_MAP[ib.data.value]()) -end - -local function pump_status_handler(driver, device, ib, response) - if ib.data.value == clusters.PumpConfigurationAndControl.types.PumpStatusBitmap.LOCAL_OVERRIDE then - device:set_field(IS_LOCAL_OVERRIDE, true, {persist = true}) - device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.localSetting()) - local supported_op_modes = {} - local supported_control_modes = {} - device:emit_event(pumpOperationMode.supportedOperationModes(supported_op_modes)) - device:emit_event(pumpControlMode.supportedControlModes(supported_control_modes)) - elseif ib.data.value == clusters.PumpConfigurationAndControl.types.PumpStatusBitmap.RUNNING then - device:set_field(IS_LOCAL_OVERRIDE, false, {persist = true}) - device:send(clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode:read(device)) - end -end - --- Capability Handlers -- -local function handle_switch_on(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.OnOff.server.commands.On(device, endpoint_id) - device:send(req) -end - -local function handle_switch_off(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.OnOff.server.commands.Off(device, endpoint_id) - device:send(req) -end - -local function handle_set_level(driver, device, cmd) - local endpoint_id = device:component_to_endpoint(cmd.component) - local level = math.floor(cmd.args.level / MAX_CAP_SWITCH_LEVEL * MAX_PUMP_ATTR_LEVEL) - local req = clusters.LevelControl.server.commands.MoveToLevelWithOnOff(device, endpoint_id, level, cmd.args.rate or 0, 0 ,0) - device:send(req) -end - -local function set_operation_mode(driver, device, cmd) - local mode_id = nil - for id, mode in pairs(PUMP_OPERATION_MODE_MAP) do - if mode.NAME == cmd.args.operationMode then - mode_id = id - break - end - end - if mode_id then - device:send(clusters.PumpConfigurationAndControl.attributes.OperationMode:write(device, device:component_to_endpoint(cmd.component), mode_id)) - end -end - -local function set_control_mode(driver, device, cmd) - local mode_id = nil - for id, mode in pairs(PUMP_CONTROL_MODE_MAP) do - if mode.NAME == cmd.args.controlMode then - mode_id = id - break - end - end - if mode_id then - device:send(clusters.PumpConfigurationAndControl.attributes.ControlMode:write(device, device:component_to_endpoint(cmd.component), mode_id)) - end -end - -local matter_driver_template = { - lifecycle_handlers = { - init = device_init, - doConfigure = do_configure, - infoChanged = info_changed, - }, - matter_handlers = { - attr = { - [clusters.OnOff.ID] = { - [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler, - }, - [clusters.LevelControl.ID] = { - [clusters.LevelControl.attributes.CurrentLevel.ID] = level_attr_handler - }, - [clusters.PumpConfigurationAndControl.ID] = { - [clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode.ID] = effective_operation_mode_handler, - [clusters.PumpConfigurationAndControl.attributes.EffectiveControlMode.ID] = effective_control_mode_handler, - [clusters.PumpConfigurationAndControl.attributes.PumpStatus.ID] = pump_status_handler, - }, - }, - }, - subscribed_attributes = subscribed_attributes, - capability_handlers = { - [capabilities.switch.ID] = { - [capabilities.switch.commands.on.NAME] = handle_switch_on, - [capabilities.switch.commands.off.NAME] = handle_switch_off, - }, - [capabilities.switchLevel.ID] = { - [capabilities.switchLevel.commands.setLevel.NAME] = handle_set_level, - }, - [capabilities.pumpOperationMode.ID] = { - [capabilities.pumpOperationMode.commands.setOperationMode.NAME] = set_operation_mode, - }, - [capabilities.pumpControlMode.ID] = { - [capabilities.pumpControlMode.commands.setControlMode.NAME] = set_control_mode, - }, - }, - supported_capabilities = { - capabilities.switch, - capabilities.switchLevel, - capabilities.pumpOperationMode, - capabilities.pumpControlMode, - }, -} - -local matter_driver = MatterDriver("matter-pump", matter_driver_template) -log.info_with({hub_logs=true}, string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) -matter_driver:run() \ No newline at end of file +-- Copyright 2024 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. + +local capabilities = require "st.capabilities" +local log = require "log" +local clusters = require "st.matter.clusters" +local embedded_cluster_utils = require "embedded-cluster-utils" +local MatterDriver = require "st.matter.driver" + +local IS_LOCAL_OVERRIDE = "__is_local_override" +-- Per matter spec, the pump level is in steps of 0.5% and the +-- max level value is 200. Anything above is considered 100% +local MAX_PUMP_ATTR_LEVEL = 200 +local MAX_CAP_SWITCH_LEVEL = 100 + +-- Include driver-side definitions when lua libs api version is < 10 +local version = require "version" +if version.api < 10 then + clusters.PumpConfigurationAndControl = require "PumpConfigurationAndControl" +end + +local pumpOperationMode = capabilities.pumpOperationMode +local pumpControlMode = capabilities.pumpControlMode + +local PUMP_OPERATION_MODE_MAP = { + [clusters.PumpConfigurationAndControl.types.OperationModeEnum.NORMAL] = pumpOperationMode.operationMode.normal, + [clusters.PumpConfigurationAndControl.types.OperationModeEnum.MINIMUM] = pumpOperationMode.operationMode.minimum, + [clusters.PumpConfigurationAndControl.types.OperationModeEnum.MAXIMUM] = pumpOperationMode.operationMode.maximum, + [clusters.PumpConfigurationAndControl.types.OperationModeEnum.LOCAL] = pumpOperationMode.operationMode.localSetting, +} + +local PUMP_CONTROL_MODE_MAP = { + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_SPEED] = pumpControlMode.controlMode.constantSpeed, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_PRESSURE] = pumpControlMode.controlMode.constantPressure, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.PROPORTIONAL_PRESSURE] = pumpControlMode.controlMode.proportionalPressure, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_FLOW] = pumpControlMode.controlMode.constantFlow, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_TEMPERATURE] = pumpControlMode.controlMode.constantTemperature, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.AUTOMATIC] = pumpControlMode.controlMode.automatic, +} + +local PUMP_CURRENT_CONTROL_MODE_MAP = { + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_SPEED] = pumpControlMode.currentControlMode.constantSpeed, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_PRESSURE] = pumpControlMode.currentControlMode.constantPressure, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.PROPORTIONAL_PRESSURE] = pumpControlMode.currentControlMode.proportionalPressure, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_FLOW] = pumpControlMode.currentControlMode.constantFlow, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.CONSTANT_TEMPERATURE] = pumpControlMode.currentControlMode.constantTemperature, + [clusters.PumpConfigurationAndControl.types.ControlModeEnum.AUTOMATIC] = pumpControlMode.currentControlMode.automatic, +} + +local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff, + }, + [capabilities.switchLevel.ID] = { + clusters.LevelControl.attributes.CurrentLevel + }, + [capabilities.pumpOperationMode.ID]={ + clusters.PumpConfigurationAndControl.attributes.OperationMode, + clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode, + clusters.PumpConfigurationAndControl.attributes.PumpStatus, + }, + [capabilities.pumpControlMode.ID]={ + clusters.PumpConfigurationAndControl.attributes.EffectiveControlMode, + }, +} + +local function find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = embedded_cluster_utils.get_endpoints(device, cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then --0 is the matter RootNode endpoint + return v + end + end + device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) + return res +end + +local function component_to_endpoint(device, component_name) + -- Use the find_default_endpoint function to return the first endpoint that + -- supports a given cluster. + return find_default_endpoint(device, clusters.PumpConfigurationAndControl.ID) +end + +local function device_init(driver, device) + device:subscribe() + device:set_component_to_endpoint_fn(component_to_endpoint) +end + +local function info_changed(driver, device, event, args) + --Note this is needed because device:subscribe() does not recalculate + -- the subscribed attributes each time it is run, that only happens at init. + -- This will change in the 0.48.x release of the lua libs. + for cap_id, attributes in pairs(subscribed_attributes) do + if device:supports_capability_by_id(cap_id) then + for _, attr in ipairs(attributes) do + device:add_subscribed_attribute(attr) + end + end + end + device:subscribe() +end + +local function set_supported_op_mode(driver, device) + local spd_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_SPEED}) + local local_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.LOCAL_OPERATION}) + local supported_op_modes = {pumpOperationMode.operationMode.normal.NAME} + if #spd_eps > 0 then + table.insert(supported_op_modes, pumpOperationMode.operationMode.minimum.NAME) + table.insert(supported_op_modes, pumpOperationMode.operationMode.maximum.NAME) + end + if #local_eps > 0 then + table.insert(supported_op_modes, pumpOperationMode.operationMode.localSetting.NAME) + end + device:emit_event(pumpOperationMode.supportedOperationModes(supported_op_modes)) +end + +local function set_supported_control_mode(driver, device) + local spd_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_SPEED}) + local prsconst_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_PRESSURE}) + local prscomp_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.COMPENSATED_PRESSURE}) + local flw_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_FLOW}) + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.CONSTANT_TEMPERATURE}) + local auto_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID, {feature_bitmap = clusters.PumpConfigurationAndControl.types.Feature.AUTOMATIC}) + local supported_control_modes = {} + if #spd_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.constantSpeed.NAME) + end + if #prsconst_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.constantPressure.NAME) + end + if #prscomp_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.proportionalPressure.NAME) + end + if #flw_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.constantFlow.NAME) + end + if #temp_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.constantTemperature.NAME) + end + if #auto_eps > 0 then + table.insert(supported_control_modes, pumpControlMode.controlMode.automatic.NAME) + end + device:emit_event(pumpControlMode.supportedControlModes(supported_control_modes)) +end + +local function do_configure(driver, device) + local pump_eps = embedded_cluster_utils.get_endpoints(device, clusters.PumpConfigurationAndControl.ID) + local level_eps = embedded_cluster_utils.get_endpoints(device, clusters.LevelControl.ID) + local profile_name = "pump" + if #pump_eps == 1 then + if #level_eps > 0 then + profile_name = profile_name .. "-level" + else + profile_name = profile_name .. "-only" + end + device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) + device:try_update_metadata({profile = profile_name}) + else + device.log.warn_with({hub_logs=true}, "Device does not support pump configuration and control cluster") + end + set_supported_op_mode(driver, device) + set_supported_control_mode(driver, device) +end + +-- Matter Handlers -- +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.on()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switch.switch.off()) + end +end + +local function level_attr_handler(driver, device, ib, response) + if ib.data.value ~= nil then + local level = math.floor((ib.data.value / MAX_PUMP_ATTR_LEVEL * MAX_CAP_SWITCH_LEVEL) + 0.5) + level = math.min(level, MAX_CAP_SWITCH_LEVEL) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.switchLevel.level(level)) + end +end + +local function effective_operation_mode_handler(driver, device, ib, response) + local modeEnum = clusters.PumpConfigurationAndControl.types.OperationModeEnum + local supported_control_modes = {} + local local_override = device:get_field(IS_LOCAL_OVERRIDE) + if not local_override then + set_supported_op_mode(driver, device) + end + if ib.data.value == modeEnum.NORMAL then + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.normal()) + set_supported_control_mode(driver, device) + elseif ib.data.value == modeEnum.MINIMUM then + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.minimum()) + device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) + elseif ib.data.value == modeEnum.MAXIMUM then + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.maximum()) + device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) + elseif ib.data.value == modeEnum.LOCAL then + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.localSetting()) + device:emit_event_for_endpoint(ib.endpoint_id, pumpControlMode.supportedControlModes(supported_control_modes)) + end +end + +local function effective_control_mode_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, PUMP_CURRENT_CONTROL_MODE_MAP[ib.data.value]()) +end + +local function pump_status_handler(driver, device, ib, response) + if ib.data.value == clusters.PumpConfigurationAndControl.types.PumpStatusBitmap.LOCAL_OVERRIDE then + device:set_field(IS_LOCAL_OVERRIDE, true, {persist = true}) + device:emit_event_for_endpoint(ib.endpoint_id, pumpOperationMode.currentOperationMode.localSetting()) + local supported_op_modes = {} + local supported_control_modes = {} + device:emit_event(pumpOperationMode.supportedOperationModes(supported_op_modes)) + device:emit_event(pumpControlMode.supportedControlModes(supported_control_modes)) + elseif ib.data.value == clusters.PumpConfigurationAndControl.types.PumpStatusBitmap.RUNNING then + device:set_field(IS_LOCAL_OVERRIDE, false, {persist = true}) + device:send(clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode:read(device)) + end +end + +-- Capability Handlers -- +local function handle_switch_on(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.OnOff.server.commands.On(device, endpoint_id) + device:send(req) +end + +local function handle_switch_off(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.OnOff.server.commands.Off(device, endpoint_id) + device:send(req) +end + +local function handle_set_level(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local level = math.floor(cmd.args.level / MAX_CAP_SWITCH_LEVEL * MAX_PUMP_ATTR_LEVEL) + local req = clusters.LevelControl.server.commands.MoveToLevelWithOnOff(device, endpoint_id, level, cmd.args.rate or 0, 0 ,0) + device:send(req) +end + +local function set_operation_mode(driver, device, cmd) + local mode_id = nil + for id, mode in pairs(PUMP_OPERATION_MODE_MAP) do + if mode.NAME == cmd.args.operationMode then + mode_id = id + break + end + end + if mode_id then + device:send(clusters.PumpConfigurationAndControl.attributes.OperationMode:write(device, device:component_to_endpoint(cmd.component), mode_id)) + end +end + +local function set_control_mode(driver, device, cmd) + local mode_id = nil + for id, mode in pairs(PUMP_CONTROL_MODE_MAP) do + if mode.NAME == cmd.args.controlMode then + mode_id = id + break + end + end + if mode_id then + device:send(clusters.PumpConfigurationAndControl.attributes.ControlMode:write(device, device:component_to_endpoint(cmd.component), mode_id)) + end +end + +local matter_driver_template = { + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure, + infoChanged = info_changed, + }, + matter_handlers = { + attr = { + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler, + }, + [clusters.LevelControl.ID] = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = level_attr_handler + }, + [clusters.PumpConfigurationAndControl.ID] = { + [clusters.PumpConfigurationAndControl.attributes.EffectiveOperationMode.ID] = effective_operation_mode_handler, + [clusters.PumpConfigurationAndControl.attributes.EffectiveControlMode.ID] = effective_control_mode_handler, + [clusters.PumpConfigurationAndControl.attributes.PumpStatus.ID] = pump_status_handler, + }, + }, + }, + subscribed_attributes = subscribed_attributes, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = handle_switch_on, + [capabilities.switch.commands.off.NAME] = handle_switch_off, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = handle_set_level, + }, + [capabilities.pumpOperationMode.ID] = { + [capabilities.pumpOperationMode.commands.setOperationMode.NAME] = set_operation_mode, + }, + [capabilities.pumpControlMode.ID] = { + [capabilities.pumpControlMode.commands.setControlMode.NAME] = set_control_mode, + }, + }, + supported_capabilities = { + capabilities.switch, + capabilities.switchLevel, + capabilities.pumpOperationMode, + capabilities.pumpControlMode, + }, + shared_device_thread_enabled = true, +} + +local matter_driver = MatterDriver("matter-pump", matter_driver_template) +log.info_with({hub_logs=true}, string.format("Starting %s driver, with dispatcher: %s", matter_driver.NAME, matter_driver.matter_dispatcher)) +matter_driver:run() diff --git a/drivers/SmartThings/matter-rvc/src/init.lua b/drivers/SmartThings/matter-rvc/src/init.lua index 1e52f321ee..2badac1413 100644 --- a/drivers/SmartThings/matter-rvc/src/init.lua +++ b/drivers/SmartThings/matter-rvc/src/init.lua @@ -120,6 +120,7 @@ local function driver_switched(driver, device) match_profile(driver, device) device:set_field(SERVICE_AREA_PROFILED, true, { persist = true }) device:send(clusters.RvcOperationalState.attributes.AcceptedCommandList:read()) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) end local function info_changed(driver, device, event, args) diff --git a/drivers/SmartThings/matter-rvc/src/test/test_matter_rvc.lua b/drivers/SmartThings/matter-rvc/src/test/test_matter_rvc.lua index e8d91fd1ca..e1c95249a8 100644 --- a/drivers/SmartThings/matter-rvc/src/test/test_matter_rvc.lua +++ b/drivers/SmartThings/matter-rvc/src/test/test_matter_rvc.lua @@ -218,6 +218,19 @@ local function operating_state_init() ) end +test.register_coroutine_test( + "Handle driverSwitched event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + test.socket.matter:__expect_send({mock_device.id, clusters.RvcOperationalState.attributes.AcceptedCommandList:read()}) + mock_device:expect_metadata_update({ profile = "rvc-clean-mode-service-area" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 15 + } +) + test.register_coroutine_test( "Assert profile applied over doConfigure", function() @@ -228,7 +241,10 @@ test.register_coroutine_test( mock_device.id, clusters.RvcOperationalState.attributes.AcceptedCommandList:read() }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -256,7 +272,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -281,7 +300,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -306,7 +328,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -330,7 +355,10 @@ test.register_coroutine_test( ) ) end - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -348,7 +376,10 @@ test.register_coroutine_test( clusters.RvcCleanMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, cleanMode.mode) }) end - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -386,7 +417,10 @@ test.register_coroutine_test( mock_device.id, clusters.RvcRunMode.server.commands.ChangeToMode(mock_device, APPLICATION_ENDPOINT, CLEANING_MODE.mode) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -421,7 +455,10 @@ test.register_coroutine_test( mock_device.id, clusters.RvcOperationalState.commands.GoHome(mock_device, APPLICATION_ENDPOINT) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -482,7 +519,10 @@ test.register_coroutine_test( mock_device.id, clusters.RvcOperationalState.commands.Pause(mock_device, APPLICATION_ENDPOINT) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -533,7 +573,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -587,7 +630,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -641,7 +687,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -692,7 +741,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -743,7 +795,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -975,7 +1030,10 @@ test.register_coroutine_test( capabilities.robotCleanerOperatingState.operatingState.mopCleaningPadMissing() ) ) - end + end, + { + min_api_version = 15 + } ) local locationDescriptorStruct = require "Global.types.LocationDescriptorStruct" @@ -1008,6 +1066,9 @@ test.register_message_test( {["areaId"] = 1, ["areaName"] = "0F Balcony" }, }, { visibility = { displayed = false } })) }, + }, + { + min_api_version = 15 } ) @@ -1031,6 +1092,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.serviceArea.selectedAreas({ 1,2,5 }, { visibility = { displayed = false } })) }, + }, + { + min_api_version = 15 } ) @@ -1054,6 +1118,9 @@ test.register_message_test( clusters.ServiceArea.server.commands.SelectAreas(mock_device, APPLICATION_ENDPOINT, {uint32_dt(1),uint32_dt(2)}) } }, + }, + { + min_api_version = 15 } ) @@ -1136,6 +1203,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.serviceArea.selectedAreas({ 1,2,5 },{ state_change=true})) }, + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-sensor/fingerprints.yml b/drivers/SmartThings/matter-sensor/fingerprints.yml index 0db00e11d6..dc42ad4b07 100644 --- a/drivers/SmartThings/matter-sensor/fingerprints.yml +++ b/drivers/SmartThings/matter-sensor/fingerprints.yml @@ -15,6 +15,11 @@ matterManufacturer: vendorId: 0x115F productId: 0x2005 deviceProfileName: presence-illuminance-temperature-humidity-battery + - id: "4447/8201" + deviceLabel: Spatial Multi-Sensor FP400 + vendorId: 0x115F + productId: 0x2009 + deviceProfileName: aqara-fp400 #Bosch - id: 4617/12309 deviceLabel: "Door/window contact II [M]" @@ -84,29 +89,34 @@ 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 temp/hmd sensor + deviceLabel: TIMMERFLOTTE Temperature/Humidity Sensor vendorId: 0x117C productId: 0x8005 deviceProfileName: temperature-humidity-battery - id: "4476/32774" - deviceLabel: KLIPPBOK Matter water leak sensor smart + deviceLabel: KLIPPBOK Water Leak Sensor vendorId: 0x117C productId: 0x8006 deviceProfileName: leak-battery - id: "4476/12288" - deviceLabel: MYGGSPRAY wrlss mtn sensor + deviceLabel: MYGGSPRAY Motion Sensor vendorId: 0x117C productId: 0x3000 deviceProfileName: motion-illuminance-battery - id: "4476/12289" - deviceLabel: ALPSTUGA Matter air quality sensor smart + deviceLabel: ALPSTUGA Air Quality Sensor vendorId: 0x117C productId: 0x3001 deviceProfileName: aqs-modular-temp-humidity - id: "4476/32775" - deviceLabel: MYGGBETT Matter door/window sensor smart + deviceLabel: MYGGBETT Door/Window Sensor vendorId: 0x117C productId: 0x8007 deviceProfileName: contact-battery @@ -215,6 +225,11 @@ matterManufacturer: vendorId: 0x1596 productId: 0x0001 deviceProfileName: smoke-battery + - id: "5526/2" + deviceLabel: MSC-1 + vendorId: 0x1596 + productId: 0x0002 + deviceProfileName: smoke-co-comeas-battery # Siterwell - id: "4736/847" deviceLabel: Siterwell Door Window Sensor @@ -345,3 +360,8 @@ matterGeneric: deviceTypes: - id: 0x0306 # Flow Sensor deviceProfileName: flow-battery + - id: "matter/soil/sensor" + deviceLabel: Matter Soil Sensor + deviceTypes: + - id: 0x0045 # Soil Sensor + deviceProfileName: soil-sensor-battery diff --git a/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml b/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml new file mode 100644 index 0000000000..d4c9fde930 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/aqara-fp400.yml @@ -0,0 +1,70 @@ +name: aqara-fp400 +components: +- id: main + capabilities: + - id: presenceSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PresenceSensor +- id: sensor1 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor2 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor3 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor4 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor5 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor6 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor7 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor +- id: sensor8 + capabilities: + - id: presenceSensor + version: 1 + optional: true + categories: + - name: PresenceSensor diff --git a/drivers/SmartThings/matter-sensor/profiles/soil-sensor-battery.yml b/drivers/SmartThings/matter-sensor/profiles/soil-sensor-battery.yml new file mode 100644 index 0000000000..07c9162e4d --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/soil-sensor-battery.yml @@ -0,0 +1,17 @@ +name: soil-sensor-battery +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/soil-sensor-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/soil-sensor-batteryLevel.yml new file mode 100644 index 0000000000..cfda9b9e95 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/soil-sensor-batteryLevel.yml @@ -0,0 +1,17 @@ +name: soil-sensor-batteryLevel +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/soil-sensor.yml b/drivers/SmartThings/matter-sensor/profiles/soil-sensor.yml new file mode 100644 index 0000000000..280edc80c4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/soil-sensor.yml @@ -0,0 +1,15 @@ +name: soil-sensor +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-battery.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-battery.yml new file mode 100644 index 0000000000..73db53e7c1 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-battery.yml @@ -0,0 +1,21 @@ +name: temperature-soil-sensor-battery +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-batteryLevel.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-batteryLevel.yml new file mode 100644 index 0000000000..55ccc8fb25 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor-batteryLevel.yml @@ -0,0 +1,21 @@ +name: temperature-soil-sensor-batteryLevel +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor.yml b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor.yml new file mode 100644 index 0000000000..bc327f01b0 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/profiles/temperature-soil-sensor.yml @@ -0,0 +1,19 @@ +name: temperature-soil-sensor +components: +- id: main + capabilities: + - id: relativeHumidityMeasurement + version: 1 + - id: temperatureMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: HumiditySensor +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/init.lua new file mode 100644 index 0000000000..98c23abef4 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/init.lua @@ -0,0 +1,5 @@ +local GlobalTypes = require "embedded_clusters.Global.types" + +local Global = {} +Global.types = GlobalTypes +return Global diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/LevelValueEnum.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/LevelValueEnum.lua new file mode 100644 index 0000000000..ed8b969b49 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/LevelValueEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local LevelValueEnum = {} +local new_mt = UintABC.new_mt({NAME = "Uint8", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNKNOWN] = "UNKNOWN", + [self.LOW] = "LOW", + [self.MEDIUM] = "MEDIUM", + [self.HIGH] = "HIGH", + [self.CRITICAL] = "CRITICAL", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNKNOWN = 0x00 +new_mt.__index.LOW = 0x01 +new_mt.__index.MEDIUM = 0x02 +new_mt.__index.HIGH = 0x03 +new_mt.__index.CRITICAL = 0x04 + +LevelValueEnum.UNKNOWN = 0x00 +LevelValueEnum.LOW = 0x01 +LevelValueEnum.MEDIUM = 0x02 +LevelValueEnum.HIGH = 0x03 +LevelValueEnum.CRITICAL = 0x04 + +LevelValueEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LevelValueEnum, new_mt) + +return LevelValueEnum diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyRangeStruct.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyRangeStruct.lua new file mode 100644 index 0000000000..b298a66961 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyRangeStruct.lua @@ -0,0 +1,119 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local MeasurementAccuracyRangeStruct = {} +local new_mt = StructureABC.new_mt({NAME = "MeasurementAccuracyRangeStruct", ID = data_types.name_to_id_map["Structure"]}) + +MeasurementAccuracyRangeStruct.field_defs = { + { + name = "range_min", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "range_max", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "percent_max", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "percent_min", + field_id = 3, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "percent_typical", + field_id = 4, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "fixed_max", + field_id = 5, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "fixed_min", + field_id = 6, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, + { + name = "fixed_typical", + field_id = 7, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint64", + }, +} + +MeasurementAccuracyRangeStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +MeasurementAccuracyRangeStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = MeasurementAccuracyRangeStruct.init +new_mt.__index.serialize = MeasurementAccuracyRangeStruct.serialize + +MeasurementAccuracyRangeStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(MeasurementAccuracyRangeStruct, new_mt) + +return MeasurementAccuracyRangeStruct diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyStruct.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyStruct.lua new file mode 100644 index 0000000000..4da8857164 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementAccuracyStruct.lua @@ -0,0 +1,99 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local MeasurementAccuracyStruct = {} +local new_mt = StructureABC.new_mt({NAME = "MeasurementAccuracyStruct", ID = data_types.name_to_id_map["Structure"]}) + +MeasurementAccuracyStruct.field_defs = { + { + name = "measurement_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "embedded_clusters.Global.types.MeasurementTypeEnum", + }, + { + name = "measured", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "min_measured_value", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "max_measured_value", + field_id = 3, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "accuracy_ranges", + field_id = 4, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.Global.types.MeasurementAccuracyRangeStruct", + }, +} + +MeasurementAccuracyStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +MeasurementAccuracyStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = MeasurementAccuracyStruct.init +new_mt.__index.serialize = MeasurementAccuracyStruct.serialize + +MeasurementAccuracyStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(MeasurementAccuracyStruct, new_mt) + +return MeasurementAccuracyStruct 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 new file mode 100644 index 0000000000..334ad78331 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/MeasurementTypeEnum.lua @@ -0,0 +1,75 @@ +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["Uint16"]}, 2) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.UNSPECIFIED] = "UNSPECIFIED", + [self.VOLTAGE] = "VOLTAGE", + [self.ACTIVE_CURRENT] = "ACTIVE_CURRENT", + [self.REACTIVE_CURRENT] = "REACTIVE_CURRENT", + [self.APPARENT_CURRENT] = "APPARENT_CURRENT", + [self.ACTIVE_POWER] = "ACTIVE_POWER", + [self.REACTIVE_POWER] = "REACTIVE_POWER", + [self.APPARENT_POWER] = "APPARENT_POWER", + [self.RMS_VOLTAGE] = "RMS_VOLTAGE", + [self.RMS_CURRENT] = "RMS_CURRENT", + [self.RMS_POWER] = "RMS_POWER", + [self.FREQUENCY] = "FREQUENCY", + [self.POWER_FACTOR] = "POWER_FACTOR", + [self.NEUTRAL_CURRENT] = "NEUTRAL_CURRENT", + [self.ELECTRICAL_ENERGY] = "ELECTRICAL_ENERGY", + [self.REACTIVE_ENERGY] = "REACTIVE_ENERGY", + [self.APPARENT_ENERGY] = "APPARENT_ENERGY", + [self.SOIL_MOISTURE] = "SOIL_MOISTURE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.UNSPECIFIED = 0x00 +new_mt.__index.VOLTAGE = 0x01 +new_mt.__index.ACTIVE_CURRENT = 0x02 +new_mt.__index.REACTIVE_CURRENT = 0x03 +new_mt.__index.APPARENT_CURRENT = 0x04 +new_mt.__index.ACTIVE_POWER = 0x05 +new_mt.__index.REACTIVE_POWER = 0x06 +new_mt.__index.APPARENT_POWER = 0x07 +new_mt.__index.RMS_VOLTAGE = 0x08 +new_mt.__index.RMS_CURRENT = 0x09 +new_mt.__index.RMS_POWER = 0x0A +new_mt.__index.FREQUENCY = 0x0B +new_mt.__index.POWER_FACTOR = 0x0C +new_mt.__index.NEUTRAL_CURRENT = 0x0D +new_mt.__index.ELECTRICAL_ENERGY = 0x0E +new_mt.__index.REACTIVE_ENERGY = 0x0F +new_mt.__index.APPARENT_ENERGY = 0x10 +new_mt.__index.SOIL_MOISTURE = 0x11 + +MeasurementTypeEnum.UNSPECIFIED = 0x00 +MeasurementTypeEnum.VOLTAGE = 0x01 +MeasurementTypeEnum.ACTIVE_CURRENT = 0x02 +MeasurementTypeEnum.REACTIVE_CURRENT = 0x03 +MeasurementTypeEnum.APPARENT_CURRENT = 0x04 +MeasurementTypeEnum.ACTIVE_POWER = 0x05 +MeasurementTypeEnum.REACTIVE_POWER = 0x06 +MeasurementTypeEnum.APPARENT_POWER = 0x07 +MeasurementTypeEnum.RMS_VOLTAGE = 0x08 +MeasurementTypeEnum.RMS_CURRENT = 0x09 +MeasurementTypeEnum.RMS_POWER = 0x0A +MeasurementTypeEnum.FREQUENCY = 0x0B +MeasurementTypeEnum.POWER_FACTOR = 0x0C +MeasurementTypeEnum.NEUTRAL_CURRENT = 0x0D +MeasurementTypeEnum.ELECTRICAL_ENERGY = 0x0E +MeasurementTypeEnum.REACTIVE_ENERGY = 0x0F +MeasurementTypeEnum.APPARENT_ENERGY = 0x10 +MeasurementTypeEnum.SOIL_MOISTURE = 0x11 + +MeasurementTypeEnum.augment_type = function(_cls, val) + setmetatable(val, new_mt) +end + +setmetatable(MeasurementTypeEnum, new_mt) + +return MeasurementTypeEnum diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/init.lua new file mode 100644 index 0000000000..984c5e02d8 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/Global/types/init.lua @@ -0,0 +1,14 @@ +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.Global.types." .. key) + end + return types_mt.__types_cache[key] +end + +local GlobalTypes = {} + +setmetatable(GlobalTypes, types_mt) + +return GlobalTypes diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/init.lua new file mode 100644 index 0000000000..a7db91daea --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/init.lua @@ -0,0 +1,46 @@ +local cluster_base = require "st.matter.cluster_base" +local SoilMeasurementServerAttributes = require "embedded_clusters.SoilMeasurement.server.attributes" + +local SoilMeasurement = {} + +SoilMeasurement.ID = 0x0430 +SoilMeasurement.NAME = "SoilMeasurement" +SoilMeasurement.server = {} +SoilMeasurement.client = {} +SoilMeasurement.server.attributes = SoilMeasurementServerAttributes:set_parent_cluster(SoilMeasurement) + +function SoilMeasurement:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "SoilMoistureMeasurementLimits", + [0x0001] = "SoilMoistureMeasuredValue", + [0xFFF9] = "AcceptedCommandList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +SoilMeasurement.attribute_direction_map = { + ["SoilMoistureMeasurementLimits"] = "server", + ["SoilMoistureMeasuredValue"] = "server", + ["AcceptedCommandList"] = "server", + ["AttributeList"] = "server", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = SoilMeasurement.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, SoilMeasurement.NAME)) + end + return SoilMeasurement[direction].attributes[key] +end +SoilMeasurement.attributes = {} +setmetatable(SoilMeasurement.attributes, attribute_helper_mt) + +setmetatable(SoilMeasurement, {__index = cluster_base}) + +return SoilMeasurement diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasuredValue.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasuredValue.lua new file mode 100644 index 0000000000..5df92ec62a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasuredValue.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SoilMoistureMeasuredValue = { + ID = 0x0001, + NAME = "SoilMoistureMeasuredValue", + base_type = require "st.matter.data_types.Uint8", +} + +function SoilMoistureMeasuredValue:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function SoilMoistureMeasuredValue:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SoilMoistureMeasuredValue:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SoilMoistureMeasuredValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SoilMoistureMeasuredValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SoilMoistureMeasuredValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(SoilMoistureMeasuredValue, {__call = SoilMoistureMeasuredValue.new_value, __index = SoilMoistureMeasuredValue.base_type}) +return SoilMoistureMeasuredValue diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasurementLimits.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasurementLimits.lua new file mode 100644 index 0000000000..6fbf1a8c8a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/SoilMoistureMeasurementLimits.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SoilMoistureMeasurementLimits = { + ID = 0x0000, + NAME = "SoilMoistureMeasurementLimits", + base_type = require "embedded_clusters.Global.types.MeasurementAccuracyStruct", +} + +function SoilMoistureMeasurementLimits:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function SoilMoistureMeasurementLimits:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SoilMoistureMeasurementLimits:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function SoilMoistureMeasurementLimits:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SoilMoistureMeasurementLimits:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function SoilMoistureMeasurementLimits:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SoilMoistureMeasurementLimits, {__call = SoilMoistureMeasurementLimits.new_value, __index = SoilMoistureMeasurementLimits.base_type}) +return SoilMoistureMeasurementLimits diff --git a/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/init.lua b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/init.lua new file mode 100644 index 0000000000..3741efd72a --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/embedded_clusters/SoilMeasurement/server/attributes/init.lua @@ -0,0 +1,19 @@ +local attr_mt = {} +attr_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.SoilMeasurement.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local SoilMeasurementServerAttributes = {} + +function SoilMeasurementServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(SoilMeasurementServerAttributes, attr_mt) + +return SoilMeasurementServerAttributes diff --git a/drivers/SmartThings/matter-sensor/src/init.lua b/drivers/SmartThings/matter-sensor/src/init.lua index 73e34fcd56..6dbd6d9811 100644 --- a/drivers/SmartThings/matter-sensor/src/init.lua +++ b/drivers/SmartThings/matter-sensor/src/init.lua @@ -37,6 +37,12 @@ if version.api < 11 then clusters.BooleanStateConfiguration = require "embedded_clusters.BooleanStateConfiguration" end +-- Include driver-side definitions when lua libs api version is < 21 +-- TODO: change this to < 20 once the lua libs have been updated for hub-core 61 +if version.api < 21 then + clusters.SoilMeasurement = require "embedded_clusters.SoilMeasurement" +end + local SensorLifecycleHandlers = {} function SensorLifecycleHandlers.do_configure(driver, device) @@ -117,6 +123,10 @@ local matter_driver_template = { [clusters.RelativeHumidityMeasurement.ID] = { [clusters.RelativeHumidityMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.humidity_measured_value_handler }, + [clusters.SoilMeasurement.ID] = { + [clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue.ID] = attribute_handlers.soil_moisture_measured_value_handler, + [clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits.ID] = attribute_handlers.soil_moisture_measurement_limits_handler + }, [clusters.TemperatureMeasurement.ID] = { [clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.temperature_measured_value_handler, [clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.temperature_measured_value_bounds_factory(fields.TEMP_MIN), @@ -164,7 +174,9 @@ local matter_driver_template = { clusters.BooleanState.attributes.StateValue, }, [capabilities.relativeHumidityMeasurement.ID] = { - clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue, + clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits }, [capabilities.temperatureAlarm.ID] = { clusters.BooleanState.attributes.StateValue, @@ -243,9 +255,6 @@ local matter_driver_template = { clusters.RadonConcentrationMeasurement.attributes.MeasuredValue, clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit, }, - [capabilities.relativeHumidityMeasurement.ID] = { - clusters.RelativeHumidityMeasurement.attributes.MeasuredValue - }, [capabilities.tvocHealthConcern.ID] = { clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue }, @@ -296,6 +305,7 @@ local matter_driver_template = { capabilities.flowMeasurement, }, sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } local matter_driver = MatterDriver("matter-sensor", matter_driver_template) 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 9bba480855..514bb622cf 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_handlers/attribute_handlers.lua @@ -9,6 +9,16 @@ local fields = require "sensor_utils.fields" local device_cfg = require "sensor_utils.device_configuration" local version = require "version" +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 = {} @@ -69,16 +79,42 @@ function AttributeHandlers.humidity_measured_value_handler(driver, device, ib, r end +-- [[ SOIL MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.soil_moisture_measured_value_handler(driver, device, ib, response) + if ib.data.value == nil then return end + local min = sensor_utils.get_field_for_endpoint(device, fields.SOIL_LIMIT_MIN, ib.endpoint_id) or sensor_utils.SOIL_MOISTURE_MIN + local max = sensor_utils.get_field_for_endpoint(device, fields.SOIL_LIMIT_MAX, ib.endpoint_id) or sensor_utils.SOIL_MOISTURE_MAX + local soil_moisture = st_utils.clamp_value(ib.data.value, min, max) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.relativeHumidityMeasurement.humidity(soil_moisture)) +end + +function AttributeHandlers.soil_moisture_measurement_limits_handler(driver, device, ib, response) + 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 + device.log.warn_with({hub_logs = true}, string.format("Device reported invalid soil moisture limits: min=%s, max=%s", min_val, max_val)) + return + end + sensor_utils.set_field_for_endpoint(device, fields.SOIL_LIMIT_MIN, ib.endpoint_id, min_val) + sensor_utils.set_field_for_endpoint(device, fields.SOIL_LIMIT_MAX, ib.endpoint_id, max_val) +end + + -- [[ BOOLEAN STATE CLUSTER ATTRIBUTES ]] -- function AttributeHandlers.boolean_state_value_handler(driver, device, ib, response) local name for dt_name, _ in pairs(fields.BOOLEAN_DEVICE_TYPE_INFO) do - local dt_ep_id = device:get_field(dt_name) - if ib.endpoint_id == dt_ep_id then - name = dt_name - break - end + local dt_ep_id = device:get_field(dt_name) + if ib.endpoint_id == dt_ep_id then + name = dt_name + break + end end if name then device:emit_event_for_endpoint(ib.endpoint_id, fields.BOOLEAN_CAP_EVENT_MAP[ib.data.value][name]) @@ -149,9 +185,9 @@ end function AttributeHandlers.occupancy_measured_value_handler(driver, device, ib, response) if device:supports_capability(capabilities.motionSensor) then - device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) + device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) else - device:emit_event(ib.data.value == 0x01 and capabilities.presenceSensor.presence("present") or capabilities.presenceSensor.presence("not present")) + device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and capabilities.presenceSensor.presence("present") or capabilities.presenceSensor.presence("not present")) end end @@ -201,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/sensor_utils/device_configuration.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/device_configuration.lua index 2c5f38524a..4690a3e356 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/device_configuration.lua @@ -11,18 +11,22 @@ if version.api < 11 then clusters.BooleanStateConfiguration = require "embedded_clusters.BooleanStateConfiguration" end +if version.api < 21 then + clusters.SoilMeasurement = require "embedded_clusters.SoilMeasurement" +end + local DeviceConfiguration = {} function DeviceConfiguration.set_boolean_device_type_per_endpoint(driver, device) for _, ep in ipairs(device.endpoints) do - for _, dt in ipairs(ep.device_types) do - for dt_name, info in pairs(fields.BOOLEAN_DEVICE_TYPE_INFO) do - if dt.device_type_id == info.id then - device:set_field(dt_name, ep.endpoint_id, { persist = true }) - device:send(clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:read(device, ep.endpoint_id)) - end - end + for _, dt in ipairs(ep.device_types) do + for dt_name, info in pairs(fields.BOOLEAN_DEVICE_TYPE_INFO) do + if dt.device_type_id == info.id then + device:set_field(dt_name, ep.endpoint_id, { persist = true }) + device:send(clusters.BooleanStateConfiguration.attributes.SupportedSensitivityLevels:read(device, ep.endpoint_id)) + end end + end end end @@ -53,12 +57,18 @@ function DeviceConfiguration.match_profile(driver, device, battery_supported) profile_name = profile_name .. "-illuminance" end - if device:supports_capability(capabilities.temperatureMeasurement) then + if device:supports_capability(capabilities.temperatureMeasurement) or + #device:get_endpoints(clusters.TemperatureMeasurement.ID) > 0 then profile_name = profile_name .. "-temperature" end if device:supports_capability(capabilities.relativeHumidityMeasurement) then - profile_name = profile_name .. "-humidity" + if #embedded_cluster_utils.get_endpoints(device, clusters.SoilMeasurement.ID) > 0 then + -- TODO: Update soil sensor profiles to use the SoilSensor category once it is available. + profile_name = profile_name .. "-soil-sensor" + else + profile_name = profile_name .. "-humidity" + end end if device:supports_capability(capabilities.atmosphericPressureMeasurement) then @@ -107,7 +117,7 @@ function DeviceConfiguration.match_profile(driver, device, battery_supported) if #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.ACTIVE_INFRARED}) > 0 or #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.RADAR}) > 0 or #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.RF_SENSING}) > 0 or - #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.VISION}) then + #device:get_endpoints(clusters.OccupancySensing.ID, {feature_bitmap = clusters.OccupancySensing.types.Feature.VISION}) > 0 then occupancy_support = "-presence" end end @@ -117,8 +127,7 @@ function DeviceConfiguration.match_profile(driver, device, battery_supported) -- remove leading "-" profile_name = string.sub(profile_name, 2) - device.log.info_with({hub_logs=true}, string.format("Updating device profile to %s.", profile_name)) device:try_update_metadata({profile = profile_name}) end -return DeviceConfiguration \ No newline at end of file +return DeviceConfiguration diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/embedded_cluster_utils.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/embedded_cluster_utils.lua index e2384098d9..37623d57d2 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/embedded_cluster_utils.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/embedded_cluster_utils.lua @@ -25,6 +25,10 @@ if version.api < 11 then clusters.BooleanStateConfiguration = require "embedded_clusters.BooleanStateConfiguration" end +if version.api < 21 then + clusters.SoilMeasurement = require "embedded_clusters.SoilMeasurement" +end + local embedded_cluster_utils = {} local embedded_clusters_api_10 = { @@ -46,38 +50,44 @@ local embedded_clusters_api_11 = { [clusters.BooleanStateConfiguration.ID] = clusters.BooleanStateConfiguration } +local embedded_clusters_api_21 = { + [clusters.SoilMeasurement.ID] = clusters.SoilMeasurement +} + function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) - -- If using older lua libs and need to check for an embedded cluster feature, - -- we must use the embedded cluster definitions here - if version.api < 10 and embedded_clusters_api_10[cluster_id] ~= nil or - version.api < 11 and embedded_clusters_api_11[cluster_id] ~= nil then - local embedded_cluster = embedded_clusters_api_10[cluster_id] or embedded_clusters_api_11[cluster_id] - local opts = opts or {} - if utils.table_size(opts) > 1 then - device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") - return - end - local clus_has_features = function(clus, feature_bitmap) - if not feature_bitmap or not clus then return false end - return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) - end - local eps = {} - for _, ep in ipairs(device.endpoints) do - for _, clus in ipairs(ep.clusters) do - if ((clus.cluster_id == cluster_id) - and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) - and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") - or (opts.cluster_type == clus.cluster_type)) - or (cluster_id == nil)) then - table.insert(eps, ep.endpoint_id) - if cluster_id == nil then break end - end + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if version.api < 10 and embedded_clusters_api_10[cluster_id] ~= nil or + version.api < 11 and embedded_clusters_api_11[cluster_id] ~= nil or + version.api < 21 and embedded_clusters_api_21[cluster_id] ~= nil then + local embedded_cluster = embedded_clusters_api_10[cluster_id] or embedded_clusters_api_11[cluster_id] or + embedded_clusters_api_21[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end end end - return eps - else - return device:get_endpoints(cluster_id, opts) end + return eps + else + return device:get_endpoints(cluster_id, opts) end +end - return embedded_cluster_utils \ No newline at end of file +return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua index f0b2a5c7a6..232e431277 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/fields.lua @@ -11,6 +11,8 @@ SensorFields.TEMP_MAX = "__temp_max" SensorFields.FLOW_BOUND_RECEIVED = "__flow_bound_received" SensorFields.FLOW_MIN = "__flow_min" SensorFields.FLOW_MAX = "__flow_max" +SensorFields.SOIL_LIMIT_MIN = "__soil_limit_min" +SensorFields.SOIL_LIMIT_MAX = "__soil_limit_max" SensorFields.battery_support = { NO_BATTERY = "NO_BATTERY", @@ -47,4 +49,10 @@ SensorFields.BOOLEAN_CAP_EVENT_MAP = { } } +SensorFields.vendor_overrides = { + [0x115F] = { -- AQARA_MANUFACTURER_ID + [0x2009] = { is_aqara_fp400 = true } + } +} + return SensorFields diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/update_metadata_request.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/update_metadata_request.lua new file mode 100644 index 0000000000..abeb04e548 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/update_metadata_request.lua @@ -0,0 +1,45 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local UpdateMetadataRequest = {} +UpdateMetadataRequest.__index = UpdateMetadataRequest + +function UpdateMetadataRequest.init() + local update_metadata_request = setmetatable({}, UpdateMetadataRequest) + update_metadata_request.profile = nil + update_metadata_request.enabled_components = {} + return update_metadata_request +end + +function UpdateMetadataRequest:add_capabilities_to_component(component_id, capability_ids) + if not self.enabled_components[component_id] then + self.enabled_components[component_id] = {} + end + for _, capability_id in ipairs(capability_ids) do + table.insert(self.enabled_components[component_id], capability_id) + end + return self +end + +function UpdateMetadataRequest:add_profile(profile) + self.profile = profile + return self +end + +function UpdateMetadataRequest:formatted_enabled_components() + local formatted_enabled_components = {} + for component_id, capability_ids in pairs(self.enabled_components) do + table.insert(formatted_enabled_components, { component_id, capability_ids }) + end + return formatted_enabled_components +end + +function UpdateMetadataRequest:format_request() + local formatted_request = { + profile = self.profile, + optional_component_capabilities = self:formatted_enabled_components() + } + return formatted_request +end + +return UpdateMetadataRequest diff --git a/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua index 5a0421fb0c..75aa284aab 100644 --- a/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua +++ b/drivers/SmartThings/matter-sensor/src/sensor_utils/utils.lua @@ -1,8 +1,14 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local fields = require "sensor_utils.fields" + local utils = {} +-- Sanity check bounds for soil moisture measurement limits (percent) +utils.SOIL_MOISTURE_MIN = 0 +utils.SOIL_MOISTURE_MAX = 100 + function utils.get_field_for_endpoint(device, field, endpoint) return device:get_field(string.format("%s_%d", field, endpoint)) end @@ -11,6 +17,15 @@ function utils.set_field_for_endpoint(device, field, endpoint, value, additional device:set_field(string.format("%s_%d", field, endpoint), value, additional_params) end +function utils.get_product_override_field(device, override_key) + if device.manufacturer_info + and fields.vendor_overrides[device.manufacturer_info.vendor_id] + and fields.vendor_overrides[device.manufacturer_info.vendor_id][device.manufacturer_info.product_id] + then + return fields.vendor_overrides[device.manufacturer_info.vendor_id][device.manufacturer_info.product_id][override_key] + end +end + function utils.tbl_contains(array, value) if value == nil then return false end for _, element in pairs(array or {}) do @@ -21,4 +36,48 @@ function utils.tbl_contains(array, value) return false end +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function utils.deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not utils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return utils.deep_equals(mt_a, mt_b, opts, seen) +end + return utils diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua index 3bf4c46f73..aa8b046927 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers.lua @@ -6,5 +6,6 @@ local sub_drivers = { lazy_load_if_possible("sub_drivers.air_quality_sensor"), lazy_load_if_possible("sub_drivers.smoke_co_alarm"), lazy_load_if_possible("sub_drivers.bosch_button_contact"), + lazy_load_if_possible("sub_drivers.aqara_fp400"), } return sub_drivers diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_handlers/attribute_handlers.lua index 345f7c5782..79315fc999 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_handlers/attribute_handlers.lua @@ -4,6 +4,7 @@ local st_utils = require "st.utils" local capabilities = require "st.capabilities" local aqs_fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" +local aqs_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils" local AirQualitySensorAttributeHandlers = {} @@ -24,19 +25,16 @@ end function AirQualitySensorAttributeHandlers.measured_value_factory(capability_name, attribute, target_unit) return function(driver, device, ib, response) - if ib.data.value then - local reporting_unit = device:get_field(capability_name.."_unit") or aqs_fields.unit_default[capability_name] - local conversion_function = aqs_fields.conversion_tables[reporting_unit][target_unit] - if conversion_function then - local converted_value = conversion_function(ib.data.value) - device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = converted_value, unit = aqs_fields.unit_strings[target_unit]})) - -- handle case where device profile supports both fineDustLevel and dustLevel + if not ib.data.value then return end + local reporting_unit = device:get_field(capability_name.."_unit") or aqs_fields.unit_default[capability_name] + local converted_value = aqs_utils.convert_value_to_unit(ib.data.value, reporting_unit, target_unit, capability_name) + + if converted_value then + device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = converted_value, unit = aqs_fields.unit_strings[target_unit]})) + -- handle case where device profile supports both fineDustLevel and dustLevel if capability_name == capabilities.fineDustSensor.NAME and device:supports_capability(capabilities.dustSensor) then device:emit_event_for_endpoint(ib.endpoint_id, capabilities.dustSensor.fineDustLevel({value = converted_value, unit = aqs_fields.unit_strings[target_unit]})) end - else - device.log.info_with({hub_logs=true}, string.format("Unsupported unit conversion from %s to %s", aqs_fields.unit_strings[reporting_unit], aqs_fields.unit_strings[target_unit])) - end end end end diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua index 6e1f1a5d81..9a8215feff 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua @@ -30,54 +30,37 @@ function DeviceConfiguration.supported_level_measurements(device) return measurement_caps, level_caps end --- Match Modular Profile function DeviceConfiguration.match_profile(device) + local update_metadata_request = require "sensor_utils.update_metadata_request" + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureMeasurement.ID) local humidity_eps = embedded_cluster_utils.get_endpoints(device, clusters.RelativeHumidityMeasurement.ID) - local optional_supported_component_capabilities = {} - local main_component_capabilities = {} - local profile_name - local MAIN_COMPONENT_IDX = 1 - local CAPABILITIES_LIST_IDX = 2 + local updated_metadata = update_metadata_request.init() + local preference_tags = "" if #temp_eps > 0 then - table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + updated_metadata:add_capabilities_to_component("main", { capabilities.temperatureMeasurement.ID }) + preference_tags = preference_tags .. "-temp" end if #humidity_eps > 0 then - table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + updated_metadata:add_capabilities_to_component("main", { capabilities.relativeHumidityMeasurement.ID }) + preference_tags = preference_tags .. "-humidity" end local measurement_caps, level_caps = DeviceConfiguration.supported_level_measurements(device) + updated_metadata:add_capabilities_to_component("main", measurement_caps) + updated_metadata:add_capabilities_to_component("main", level_caps) - for _, cap_id in ipairs(measurement_caps) do - table.insert(main_component_capabilities, cap_id) - end - - for _, cap_id in ipairs(level_caps) do - table.insert(main_component_capabilities, cap_id) - end - - table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) - - if #temp_eps > 0 and #humidity_eps > 0 then - profile_name = "aqs-modular-temp-humidity" - elseif #temp_eps > 0 then - profile_name = "aqs-modular-temp" - elseif #humidity_eps > 0 then - profile_name = "aqs-modular-humidity" - else - profile_name = "aqs-modular" - end - - device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) - device:set_field(fields.MODULAR_PROFILE_UPDATED, true) + device:try_update_metadata(updated_metadata:add_profile("aqs-modular" .. preference_tags):format_request()) -- earlier modular profile gating (min api v14, rpc 8) ensures we are running >= 0.57 FW. -- This gating specifies a workaround required only for 0.57 FW, which is not needed for 0.58 and higher. if version.api < 15 or version.rpc < 9 then + local MAIN_COMPONENT_IDX = 1 + local CAPABILITIES_LIST_IDX = 2 -- add mandatory capabilities for subscription - local total_supported_capabilities = optional_supported_component_capabilities + local total_supported_capabilities = updated_metadata:formatted_enabled_components() table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.airQualityHealthConcern.ID) table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID) table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID) diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua index 0d3c5dd21c..538af70968 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua @@ -1,8 +1,9 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local log = require "log" local version = require "version" -local utils = require "st.utils" +local st_utils = require "st.utils" local clusters = require "st.matter.clusters" local capabilities = require "st.capabilities" @@ -26,8 +27,6 @@ local AirQualitySensorFields = {} AirQualitySensorFields.AIR_QUALITY_SENSOR_DEVICE_TYPE_ID = 0x002C -AirQualitySensorFields.MODULAR_PROFILE_UPDATED = "__modular_profile_updated" - AirQualitySensorFields.SUPPORTED_COMPONENT_CAPABILITIES = "__supported_component_capabilities" AirQualitySensorFields.units_required = { @@ -151,30 +150,64 @@ AirQualitySensorFields.level_strings = { [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.CRITICAL] = "hazardous", } -AirQualitySensorFields.conversion_tables = { +AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR = 24.45 + +-- measured in g/mol +AirQualitySensorFields.molecular_weights = { + [capabilities.carbonDioxideMeasurement.NAME] = 44.010, + [capabilities.nitrogenDioxideMeasurement.NAME] = 28.014, + [capabilities.ozoneMeasurement.NAME] = 48.0, + [capabilities.formaldehydeMeasurement.NAME] = 30.031, + [capabilities.veryFineDustSensor.NAME] = "N/A", + [capabilities.fineDustSensor.NAME] = "N/A", + [capabilities.dustSensor.NAME] = "N/A", + [capabilities.radonMeasurement.NAME] = 222.018, + [capabilities.tvocMeasurement.NAME] = "N/A", +} + +local function is_valid_molecular_weight(molecular_weight) + if type(molecular_weight) ~= "number" or molecular_weight <= 0 then + log.warn_with({hub_logs = true}, string.format("unit conversion molecular weight (%s) is not a valid number", molecular_weight)) + return false + end + return true +end + +AirQualitySensorFields.unit_conversion = { [units.PPM] = { - [units.PPM] = function(value) return utils.round(value) end, - [units.PPB] = function(value) return utils.round(value * (10^3)) end - }, + [units.PPM] = function(value) return st_utils.round(value) end, + [units.PPB] = function(value) return st_utils.round(value * (10^3)) end, + [units.UGM3] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * molecular_weight * 10^3) / AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR) end + end, + [units.MGM3] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * molecular_weight) / AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR) end + end,}, [units.PPB] = { - [units.PPM] = function(value) return utils.round(value/(10^3)) end, - [units.PPB] = function(value) return utils.round(value) end + [units.PPM] = function(value) return st_utils.round(value/(10^3)) end, + [units.PPB] = function(value) return st_utils.round(value) end, }, [units.PPT] = { - [units.PPM] = function(value) return utils.round(value/(10^6)) end + [units.PPM] = function(value) return st_utils.round(value/(10^6)) end }, [units.MGM3] = { - [units.UGM3] = function(value) return utils.round(value * (10^3)) end + [units.UGM3] = function(value) return st_utils.round(value * (10^3)) end, + [units.PPM] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR) / molecular_weight) end + end, }, [units.UGM3] = { - [units.UGM3] = function(value) return utils.round(value) end + [units.UGM3] = function(value) return st_utils.round(value) end, + [units.PPM] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * AirQualitySensorFields.MGM3_PPM_CONVERSION_FACTOR) / (molecular_weight * 10^3)) end + end, }, [units.NGM3] = { - [units.UGM3] = function(value) return utils.round(value/(10^3)) end + [units.UGM3] = function(value) return st_utils.round(value/(10^3)) end }, [units.BQM3] = { - [units.PCIL] = function(value) return utils.round(value/37) end - } + [units.PCIL] = function(value) return st_utils.round(value/37) end + }, } return AirQualitySensorFields diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua index 1a36f5f920..a24149cce3 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua @@ -1,6 +1,7 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local log = require "log" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils" @@ -77,4 +78,16 @@ function AirQualitySensorUtils.set_supported_health_concern_values(device) end end +function AirQualitySensorUtils.convert_value_to_unit(value, from_unit, to_unit, capability_name) + local conversion_function = fields.unit_conversion[from_unit] and fields.unit_conversion[from_unit][to_unit] + if not conversion_function then + log.info_with( {hub_logs = true} , string.format("Unsupported unit conversion from %s to %s", fields.unit_strings[from_unit], fields.unit_strings[to_unit])) + return + elseif type(value) ~= "number" then + log.info_with( {hub_logs = true} , string.format("unit conversion value (%s) is not a number", value)) + return + end + return conversion_function(value, fields.molecular_weights[capability_name]) +end + return AirQualitySensorUtils diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua index 61280fd107..1ce1cc2176 100644 --- a/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/init.lua @@ -4,6 +4,7 @@ local version = require "version" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" +local sensor_utils = require "sensor_utils.utils" local aqs_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils" local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" local attribute_handlers = require "sub_drivers.air_quality_sensor.air_quality_sensor_handlers.attribute_handlers" @@ -53,6 +54,7 @@ function AirQualitySensorLifecycleHandlers.driver_switched(driver, device) local legacy_device_cfg = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.legacy_device_configuration" legacy_device_cfg.match_profile(device) end + device:try_update_metadata({provisioning_state = "PROVISIONED"}) end function AirQualitySensorLifecycleHandlers.device_init(driver, device) @@ -66,12 +68,11 @@ function AirQualitySensorLifecycleHandlers.device_init(driver, device) end function AirQualitySensorLifecycleHandlers.info_changed(driver, device, event, args) - if device.profile.id ~= args.old_st_store.profile.id or device:get_field(fields.MODULAR_PROFILE_UPDATED) then + if not sensor_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then if device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) then --re-up subscription with new capabilities using the modular supports_capability override device:extend_device("supports_capability_by_id", aqs_utils.supports_capability_by_id_modular) end - device:set_field(fields.MODULAR_PROFILE_UPDATED, nil) aqs_utils.set_supported_health_concern_values(device) device:subscribe() end diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua new file mode 100644 index 0000000000..d28a66f147 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_fp400(opts, driver, device) + local sensor_utils = require "sensor_utils.utils" + if sensor_utils.get_product_override_field(device, "is_aqara_fp400") then + return true, require("sub_drivers.aqara_fp400") + end + return false +end + +return is_aqara_fp400 diff --git a/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua new file mode 100644 index 0000000000..d9e9ad6378 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/sub_drivers/aqara_fp400/init.lua @@ -0,0 +1,69 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local sensor_utils = require "sensor_utils.utils" + +local COMPONENT_TO_ENDPOINT_MAP = "__COMPONENT_TO_ENDPOINT_MAP" + +local function endpoint_to_component(device, endpoint_id) + local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} + for component_id, ep_id in pairs(map) do + if ep_id == endpoint_id then return component_id end + end + return "main" +end + +local function configure_profile(device) + local update_metadata_request = require "sensor_utils.update_metadata_request" + + local updated_component_to_endpoint_map = {} + local updated_metadata = update_metadata_request.init() + for _, ep in ipairs(device.endpoints) do + -- >2 since EP0 is the root node, EP1 is a permanent Presence Sensor, and EP2 is a permanent Light Sensor + if ep.endpoint_id > 2 then + local component_id = string.format("sensor%d", ep.endpoint_id - 2) -- ex. sensor1, sensor2, etc. for EP3, EP4, etc. + updated_component_to_endpoint_map[component_id] = ep.endpoint_id + updated_metadata:add_capabilities_to_component(component_id, { capabilities.presenceSensor.ID }) + end + end + device:set_field(COMPONENT_TO_ENDPOINT_MAP, updated_component_to_endpoint_map, { persist = true }) + device:try_update_metadata(updated_metadata:add_profile("aqara-fp400"):format_request()) +end + + +local Fp400LifecycleHandlers = {} + +function Fp400LifecycleHandlers.do_configure(driver, device) + configure_profile(device) +end + +function Fp400LifecycleHandlers.driver_switched(driver, device) + configure_profile(device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + +function Fp400LifecycleHandlers.info_changed(driver, device, event, args) + if not sensor_utils.deep_equals(args.old_st_store.endpoints, device.endpoints) then + configure_profile(device) + device:subscribe() + end +end + +function Fp400LifecycleHandlers.device_init(driver, device) + device:set_endpoint_to_component_fn(endpoint_to_component) + device:subscribe() +end + +local aqara_fp400_handler = { + NAME = "aqara-fp400", + lifecycle_handlers = { + doConfigure = Fp400LifecycleHandlers.do_configure, + driverSwitched = Fp400LifecycleHandlers.driver_switched, + infoChanged = Fp400LifecycleHandlers.info_changed, + init = Fp400LifecycleHandlers.device_init, + }, + can_handle = require("sub_drivers.aqara_fp400.can_handle"), +} + +return aqara_fp400_handler diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor.lua index 1988563bc8..781854ee8b 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor.lua @@ -497,7 +497,10 @@ test.register_coroutine_test( "Configure should read units from device and profile change as needed", function() test_aqs_device_type_do_configure(mock_device, "aqs-temp-humidity-all-level-all-meas") - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -505,7 +508,10 @@ test.register_coroutine_test( function() test_aqs_device_type_do_configure(mock_device_common, "aqs-temp-humidity-co2-pm25-tvoc-meas") end, - { test_init = test_init_common } + { + test_init = test_init_common, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -513,7 +519,10 @@ test.register_coroutine_test( function() test_aqs_device_type_do_configure(mock_device_level, "aqs-temp-humidity-all-level") end, - { test_init = test_init_level } + { + test_init = test_init_level, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -522,7 +531,10 @@ test.register_coroutine_test( test_aqs_device_type_do_configure(mock_device_co, "aqs-temp-humidity-all-meas") test_aqs_device_type_do_configure(mock_device_co2, "aqs-temp-humidity-co2-pm25-tvoc-meas") end, - { test_init = test_init_co_co2 } + { + test_init = test_init_co_co2, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -530,7 +542,10 @@ test.register_coroutine_test( function() test_aqs_device_type_do_configure(mock_device_tvoc, "aqs-temp-humidity-tvoc-meas") end, - { test_init = test_init_tvoc } + { + test_init = test_init_tvoc, + min_api_version = 15 + } ) @@ -551,6 +566,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -570,6 +588,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) } + }, + { + min_api_version = 14 } ) @@ -602,6 +623,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.airQualityHealthConcern.airQualityHealthConcern.hazardous()) }, + }, + { + min_api_version = 14 } ) @@ -669,7 +693,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.tvocMeasurement.tvocLevel({value = 750, unit = "ppb"})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -691,7 +718,10 @@ test.register_coroutine_test( mock_device_common:generate_test_message("main", capabilities.fineDustSensor.fineDustLevel({value = 18, unit = "μg/m^3"})) ) end, - { test_init = test_init_common } + { + test_init = test_init_common, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -796,7 +826,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.tvocHealthConcern.tvocHealthConcern.hazardous()) ) - end + end, + { + min_api_version = 14 + } ) -- run tests diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor_modular.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor_modular.lua index 5b6b43f2f5..4a18304d56 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor_modular.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_air_quality_sensor_modular.lua @@ -82,6 +82,36 @@ local mock_device_common = test.mock_device.build_test_matter_device({ } }) +local mock_device_modular_fingerprint = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqs-modular.yml", + {enabled_optional_capabilities = {{"main", {}}}}), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER", feature_map = 3}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- Air Quality Sensor + } + } + } +}) + local function test_init_all() test.mock_device.add_test_device(mock_device_all) test.socket.device_lifecycle:__queue_receive({ mock_device_all.id, "init" }) @@ -94,6 +124,18 @@ local function test_init_all() test.socket.matter:__expect_send({mock_device_all.id, subscribe_request}) end +local function test_init_modular_fingerprint() + test.mock_device.add_test_device(mock_device_modular_fingerprint) + test.socket.device_lifecycle:__queue_receive({ mock_device_modular_fingerprint.id, "init" }) + test.socket.capability:__expect_send(mock_device_modular_fingerprint:generate_test_message("main", + capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, + {visibility={displayed=false}})) + ) + -- on device create, a generic AQS device will be profiled as aqs, thus only subscribing to one attribute + local subscribe_request = clusters.AirQuality.attributes.AirQuality:subscribe(mock_device_modular_fingerprint) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, subscribe_request}) +end + local function test_init_common() test.mock_device.add_test_device(mock_device_common) test.socket.device_lifecycle:__queue_receive({ mock_device_common.id, "added" }) @@ -243,6 +285,30 @@ local function get_subscribe_request_common() return subscribe_request end +local function get_subscribe_request_tvoc() + local subscribed_attributes = { + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.tvocMeasurement.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasuredValue, + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit, + }, + } + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in pairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_modular_fingerprint) + else + subscribe_request:merge(attribute:subscribe(mock_device_modular_fingerprint)) + end + end + end + return subscribe_request +end + + -- run the profile configuration tests local function test_aqs_device_type_update_modular_profile(generic_mock_device, expected_metadata, subscribe_request, expected_supported_values_setters) test.socket.device_lifecycle:__queue_receive({generic_mock_device.id, "doConfigure"}) @@ -268,6 +334,29 @@ local function test_aqs_device_type_update_modular_profile(generic_mock_device, test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) end +test.register_coroutine_test( + "Handle driverSwitched event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_modular_fingerprint.id, "driverSwitched" }) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.CarbonMonoxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.CarbonDioxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.NitrogenDioxideConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.OzoneConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.Pm1ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.RadonConcentrationMeasurement.attributes.MeasurementUnit:read()}) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.MeasurementUnit:read()}) + mock_device_modular_fingerprint:expect_metadata_update({ profile = "aqs-modular", optional_component_capabilities = {{"main", {"tvocMeasurement"}}} }) + mock_device_modular_fingerprint:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_modular_fingerprint, + min_api_version = 15 + } +) + test.register_coroutine_test( "Device with modular profile should enable correct optional capabilities - all clusters", function() @@ -319,7 +408,10 @@ test.register_coroutine_test( local subscribe_request_all = get_subscribe_request_all() test_aqs_device_type_update_modular_profile(mock_device_all, expected_metadata_all, subscribe_request_all, expected_supported_values_setters) end, - { test_init = test_init_all } + { + test_init = test_init_all, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -346,7 +438,92 @@ test.register_coroutine_test( local subscribe_request_common = get_subscribe_request_common() test_aqs_device_type_update_modular_profile(mock_device_common, expected_metadata_common, subscribe_request_common, expected_supported_values_setters) end, - { test_init = test_init_common } + { + test_init = test_init_common, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", + function() + local expected_metadata_modular_disabled = { + optional_component_capabilities={ + { + "main", + { + "tvocMeasurement", + }, + }, + }, + profile="aqs-modular", + } + local subscribe_request_tvoc = get_subscribe_request_tvoc() + local updated_device_profile = t_utils.get_profile_definition("aqs-modular.yml", + {enabled_optional_capabilities = expected_metadata_modular_disabled.optional_component_capabilities} + ) + updated_device_profile.id = "00000000-1111-2222-3333-000000000006" + test.socket.device_lifecycle:__queue_receive(mock_device_modular_fingerprint:generate_info_changed({ profile = updated_device_profile })) + test.socket.capability:__expect_send(mock_device_modular_fingerprint:generate_test_message("main", capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, {visibility={displayed=false}}))) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, subscribe_request_tvoc}) + end, + { + test_init = test_init_modular_fingerprint, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", + function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular_fingerprint:generate_info_changed({})) + end, + { + test_init = test_init_modular_fingerprint, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", + function() + local expected_metadata_modular_disabled = { + optional_component_capabilities={ + { + "main", + { + "tvocMeasurement", + }, + }, + }, + profile="aqs-modular", + } + local subscribe_request_tvoc = get_subscribe_request_tvoc() + local updated_device_profile = t_utils.get_profile_definition("aqs-modular.yml", + {enabled_optional_capabilities = expected_metadata_modular_disabled.optional_component_capabilities} + ) + updated_device_profile.id = "00000000-1111-2222-3333-000000000006" + test.socket.device_lifecycle:__queue_receive(mock_device_modular_fingerprint:generate_info_changed({ profile = updated_device_profile })) + test.socket.capability:__expect_send(mock_device_modular_fingerprint:generate_test_message("main", capabilities.airQualityHealthConcern.supportedAirQualityValues({"unknown", "good", "unhealthy", "moderate", "slightlyUnhealthy"}, {visibility={displayed=false}}))) + test.socket.matter:__expect_send({mock_device_modular_fingerprint.id, subscribe_request_tvoc}) + end, + { + test_init = test_init_modular_fingerprint, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", + function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular_fingerprint:generate_info_changed({})) + end, + { + test_init = test_init_modular_fingerprint, + min_api_version = 15 + } ) -- run tests diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua new file mode 100644 index 0000000000..6ce8abf91d --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_aqara_fp400.lua @@ -0,0 +1,307 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local st_utils = require "st.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local sensor_utils = require "sensor_utils.utils" + +local matter_endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.IlluminanceMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0106, device_type_revision = 1} -- Light Sensor + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + }, +} + +local enabled_optional_component_capability_pairs = { + { "sensor1", { capabilities.presenceSensor.ID } }, + { "sensor3", { capabilities.presenceSensor.ID } } +} + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("aqara-fp400.yml", + { enabled_optional_capabilities = enabled_optional_component_capability_pairs } + ), + manufacturer_info = { + vendor_id = 0x115F, + product_id = 0x2009, + }, + endpoints = matter_endpoints +}) + +local function do_subscribe(dev) + local subscribe_request = clusters.OccupancySensing.attributes.Occupancy:subscribe(dev) + subscribe_request:merge(clusters.IlluminanceMeasurement.attributes.MeasuredValue:subscribe(dev)) + return subscribe_request +end + +local function test_init() + local subscribe_request = do_subscribe(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test profile change on doConfigure for FP400", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + -- When doConfigure completes successfully, the framework automatically provisions the device + mock_device:expect_metadata_update({ profile = "aqara-fp400", optional_component_capabilities = enabled_optional_component_capability_pairs }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + assert(sensor_utils.deep_equals(st_utils.deep_copy(mock_device:get_field("__COMPONENT_TO_ENDPOINT_MAP")), { + sensor1 = 3, + sensor3 = 5 + }), "Component to endpoint map should be set correctly on doConfigure") + end, + { + min_api_version = 21 + } +) + +test.register_coroutine_test( + "Test no profile change on driverSwitched for FP400", + function() + local current_profile = mock_device.profile.id + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + mock_device:expect_metadata_update({ profile = "aqara-fp400", optional_component_capabilities = enabled_optional_component_capability_pairs }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + -- Ensure profile has not changed + test.wait_for_events() + assert(mock_device.profile.id == current_profile, "Profile should not change on driverSwitched") + end, + { + min_api_version = 21 + } +) + +test.register_message_test( + "Occupancy reports should generate correct presence messages", + { + -- from EP1 + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 1, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("present")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 1, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) + }, + -- from EP3 + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("sensor1", capabilities.presenceSensor.presence("present")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("sensor1", capabilities.presenceSensor.presence("not present")) + }, + -- from EP5 + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 5, 1) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("sensor3", capabilities.presenceSensor.presence("present")) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 5, 0) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("sensor3", capabilities.presenceSensor.presence("not present")) + }, + }, + { + test_init = function () + local subscribe_request = do_subscribe(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + -- we can assume this will be set elsewhere-- see other tests. + mock_device:set_field("__COMPONENT_TO_ENDPOINT_MAP", + { sensor1 = 3, sensor3 = 5 }, { persist = true } + ) + end, + min_api_version = 14 + } +) + +test.register_message_test( + "Illuminance reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device, 2, 21370) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Test profile change on endpoints increment in infoChanged for FP400", + function() + local current_profile_id = mock_device.profile.id + local incremented_matter_endpoints = matter_endpoints + table.insert(incremented_matter_endpoints, { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor + } + }) + local incremented_enabled_optional_component_capability_pairs = { + { "sensor1", { capabilities.presenceSensor.ID } }, + { "sensor3", { capabilities.presenceSensor.ID } }, + { "sensor2", { capabilities.presenceSensor.ID } }, + } + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ endpoints = incremented_matter_endpoints })) + mock_device:expect_metadata_update({ profile = "aqara-fp400", optional_component_capabilities = incremented_enabled_optional_component_capability_pairs }) + local subscribe_request = do_subscribe(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + -- Ensure profile has not changed + test.wait_for_events() + assert(mock_device.profile.id == current_profile_id, "Profile should not change on infoChanged") + assert(sensor_utils.deep_equals(st_utils.deep_copy(mock_device:get_field("__COMPONENT_TO_ENDPOINT_MAP")), { + sensor1 = 3, + sensor3 = 5, + sensor2 = 4 + }), "Component to endpoint map should be set correctly on doConfigure") + end, + { + min_api_version = 21 + } +) + +test.register_coroutine_test( + "Test profile change on endpoints decrement in infoChanged for FP400", + function() + local current_profile_id = mock_device.profile.id + local decremented_matter_endpoints = matter_endpoints + table.remove(decremented_matter_endpoints, 4) -- remove EP3 + local decremented_enabled_optional_component_capability_pairs = { + { "sensor3", { capabilities.presenceSensor.ID } }, + { "sensor2", { capabilities.presenceSensor.ID } }, + } + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ endpoints = decremented_matter_endpoints })) + mock_device:expect_metadata_update({ profile = "aqara-fp400", optional_component_capabilities = decremented_enabled_optional_component_capability_pairs }) + local subscribe_request = do_subscribe(mock_device) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + -- Ensure profile has not changed + test.wait_for_events() + assert(mock_device.profile.id == current_profile_id, "Profile should not change on infoChanged") + assert(sensor_utils.deep_equals(st_utils.deep_copy(mock_device:get_field("__COMPONENT_TO_ENDPOINT_MAP")), { + sensor3 = 5, + sensor2 = 4 + }), "Component to endpoint map should be set correctly on doConfigure") + end, + { + min_api_version = 21 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_bosch_button_contact.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_bosch_button_contact.lua index 1ca2add2ff..d592a9089e 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_bosch_button_contact.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_bosch_button_contact.lua @@ -80,6 +80,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } + }, + { + min_api_version = 14 } ) @@ -115,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -160,6 +166,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 14 } ) @@ -181,6 +190,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 14 } ) @@ -218,6 +230,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.double({state_change = true})) }, + }, + { + min_api_version = 14 } ) @@ -240,6 +255,9 @@ test.register_message_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ), }, + }, + { + min_api_version = 14 } ) @@ -272,6 +290,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_flow_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_flow_sensor.lua index 6b1301cb96..952989f6a1 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_flow_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_flow_sensor.lua @@ -69,6 +69,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.flowMeasurement.flow({ value = 20.0, unit = "m^3/h" })) } + }, + { + min_api_version = 14 } ) @@ -96,6 +99,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.flowMeasurement.flowRange({ value = { minimum = 2.0, maximum = 500.0 }, unit = "m^3/h" })) } + }, + { + min_api_version = 14 } ) @@ -125,6 +131,9 @@ test.register_message_test( refresh_commands(mock_device) } }, + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_freeze_leak_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_freeze_leak_sensor.lua index 44a984b2a7..5cd5e85950 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_freeze_leak_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_freeze_leak_sensor.lua @@ -97,6 +97,9 @@ test.register_message_test( direction = "send", message = mock_device_freeze_leak:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 15 } ) @@ -130,6 +133,9 @@ test.register_message_test( direction = "send", message = mock_device_freeze_leak:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 15 } ) @@ -162,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_device_freeze_leak:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) } + }, + { + min_api_version = 15 } ) @@ -180,7 +189,10 @@ test.register_coroutine_test( mock_device_freeze_leak.id, clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(mock_device_freeze_leak, 2, 0) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -197,7 +209,10 @@ test.register_coroutine_test( mock_device_freeze_leak.id, clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(mock_device_freeze_leak, 2, mock_device_freeze_leak:get_field("freezeMax") - 1) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -227,7 +242,10 @@ test.register_coroutine_test( mock_device_freeze_leak.id, clusters.BooleanStateConfiguration.attributes.CurrentSensitivityLevel:write(mock_device_freeze_leak, 2, 0) }) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_pressure_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_pressure_sensor.lua index f7b3d7afe0..8920bfcf7f 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_pressure_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_pressure_sensor.lua @@ -92,6 +92,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.atmosphericPressureMeasurement.atmosphericPressure({ value = 0, unit = "kPa" })) } + }, + { + min_api_version = 14 } ) @@ -111,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) } + }, + { + min_api_version = 14 } ) @@ -140,6 +146,9 @@ test.register_message_test( refresh_commands(mock_device) } }, + }, + { + min_api_version = 14 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_rain_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_rain_sensor.lua index 21f8a0fa51..6e8c11099d 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_rain_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_rain_sensor.lua @@ -94,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_device_rain:generate_test_message("main", capabilities.rainSensor.rain.detected()) } + }, + { + min_api_version = 15 } ) @@ -126,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device_rain:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor.lua index a66e575310..8e9b8580d9 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor.lua @@ -161,6 +161,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) } + }, + { + min_api_version = 14 } ) @@ -180,6 +183,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -199,6 +205,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 14 } ) @@ -231,6 +240,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -250,6 +262,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) } + }, + { + min_api_version = 14 } ) @@ -282,6 +297,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -316,6 +334,9 @@ test.register_message_test( refresh_commands(mock_device) } }, + }, + { + min_api_version = 14 } ) @@ -343,6 +364,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 40.00 }, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -355,7 +379,10 @@ test.register_coroutine_test( }) mock_device_presence_sensor:expect_metadata_update({ profile = "presence-illuminance-temperature-humidity-battery" }) end, - { test_init = test_init_presence_sensor } + { + test_init = test_init_presence_sensor, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_battery.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_battery.lua index 8d3962de66..7393937682 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_battery.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_battery.lua @@ -86,7 +86,10 @@ test.register_coroutine_test( } ) mock_device_humidity_battery:expect_metadata_update({ profile = "humidity-battery" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -111,7 +114,10 @@ test.register_coroutine_test( } ) mock_device_humidity_battery:expect_metadata_update({ profile = "humidity-batteryLevel" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -134,7 +140,10 @@ test.register_coroutine_test( }) } ) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_featuremap.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_featuremap.lua index b1e5d39248..df9ab678cb 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_featuremap.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_featuremap.lua @@ -169,21 +169,30 @@ test.register_coroutine_test( "Test profile change on init for humidity sensor with battery", function() end, - { test_init = test_init_humidity_battery } + { + test_init = test_init_humidity_battery, + min_api_version = 15 + } ) test.register_coroutine_test( "Test profile change on init for humidity sensor without battery", function() end, - { test_init = test_init_humidity_no_battery } + { + test_init = test_init_humidity_no_battery, + min_api_version = 15 + } ) test.register_coroutine_test( "Test profile change on init for temperature-humidity sensor", function() end, - { test_init = test_init_temp_humidity } + { + test_init = test_init_temp_humidity, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_rpc.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_rpc.lua index 88af036e19..1093c1be76 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_rpc.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_sensor_rpc.lua @@ -69,7 +69,11 @@ test.register_message_test( clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 4000) } } + }, + { + min_api_version = 15 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm.lua index c7cfe26c2a..2170115366 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm.lua @@ -122,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 15 } ) @@ -167,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 15 } ) @@ -212,6 +218,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) }, + }, + { + min_api_version = 15 } ) @@ -270,6 +279,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) }, + }, + { + min_api_version = 15 } ) @@ -328,6 +340,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) }, + }, + { + min_api_version = 15 } ) @@ -373,6 +388,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.critical()), } + }, + { + min_api_version = 15 } ) @@ -415,6 +433,9 @@ test.register_message_test( direction = "send", message = {mock_device.id, clusters.SmokeCoAlarm.attributes.COState:read(mock_device)} } + }, + { + min_api_version = 15 } ) @@ -447,6 +468,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) } + }, + { + min_api_version = 15 } ) @@ -466,6 +490,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -498,6 +525,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) } + }, + { + min_api_version = 15 } ) @@ -546,6 +576,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideMeasurement.carbonMonoxideLevel({value = 10, unit = "ppm"})) } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm_battery.lua b/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm_battery.lua index 816552935e..1b96842797 100644 --- a/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm_battery.lua +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_smoke_co_alarm_battery.lua @@ -90,7 +90,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ profile = "smoke-co-temp-humidity-comeas-battery" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -102,7 +105,10 @@ test.register_coroutine_test( clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(0)}) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -119,7 +125,10 @@ test.register_coroutine_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ) ) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() 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 new file mode 100644 index 0000000000..dbdc25527e --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_matter_soil_sensor.lua @@ -0,0 +1,250 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local version = require "version" + +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({ + profile = t_utils.get_profile_definition("temperature-soil-sensor.yml"), + manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000 }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { cluster_id = clusters.SoilMeasurement.ID, cluster_type = "SERVER" }, + { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0045, device_type_revision = 1 } -- Soil Sensor + } + }, + } +}) + +local subscribe_request + +local cluster_subscribe_list = { + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue, + clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue +} + +local function test_init() + subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "temperature-soil-sensor" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test infoChanged lifecycle event", + function() + local updated_device_profile = t_utils.get_profile_definition("temperature-soil-sensor.yml") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "Relative humidity reports should generate correct messages", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 4049) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) + ) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.RelativeHumidityMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 4050) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) + ) + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "Temperature reports should generate correct messages", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.TemperatureMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 1, 40*100) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) + ) + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "Min and max temperature attributes set capability constraint", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_device, 1, 500) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 4000) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 40.00 }, unit = "C" }) + ) + ) + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "Soil moisture is reported raw when no limits are set", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue:build_test_report_data(mock_device, 1, 55) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 55 })) + ) + end +, + { + min_api_version = 15 + }) + +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, + min_measured_value = min_value, + max_measured_value = max_value, + accuracy_ranges = {clusters.Global.types.MeasurementAccuracyRangeStruct({range_min = min_value, range_max = max_value})} + }) +end + +test.register_coroutine_test( + "Soil moisture is scaled 0-100% when min and max limits are set", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits:build_test_report_data(mock_device, 1, build_soil_moisture_limits(0, 50)) + } + ) + -- Receive a measured value of 25, which is 50% when scaled between 0 and 50 + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue:build_test_report_data(mock_device, 1, 25) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 25 })) + ) + end +, + { + min_api_version = 17 + }) + +test.register_coroutine_test( + "Soil moisture scaling rounds correctly", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasurementLimits:build_test_report_data(mock_device, 1, build_soil_moisture_limits(10, 90)) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue:build_test_report_data(mock_device, 1, 10) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 10 })) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.SoilMeasurement.attributes.SoilMoistureMeasuredValue:build_test_report_data(mock_device, 1, 90) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 90 })) + ) + end +, + { + min_api_version = 17 + }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-sensor/src/test/test_unsupported_unit_conversion.lua b/drivers/SmartThings/matter-sensor/src/test/test_unsupported_unit_conversion.lua new file mode 100644 index 0000000000..2e79f88dd7 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_unsupported_unit_conversion.lua @@ -0,0 +1,63 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" + +-- Unit conversion utilities for sensor +local air_quality_sensor_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils" +local air_quality_sensor_fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields" + +local units = air_quality_sensor_fields.units + +test.register_coroutine_test( + "Unsupported unit conversion from PPT to MGM3 should return nil", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(100, units.PPT, units.MGM3, capabilities.ozoneMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported PPT to MGM3 conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unsupported unit conversion from PPB to UGM3 should return nil", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(100, units.PPB, units.UGM3, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported PPB to UGM3 conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unsupported unit conversion from NGM3 to PPM should return nil", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(100, units.NGM3, units.PPM, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported NGM3 to PPM conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Supported unit conversion from PPM to PPB should return correct value", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(100, units.PPM, units.PPB, capabilities.carbonMonoxideMeasurement.NAME) + assert(result ~= nil, "Expected a value for supported PPM to PPB conversion") + assert(result == 100000, "Expected 100000 for PPM to PPB conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Supported unit conversion from PPB to PPM should return correct value", + function() + local result = air_quality_sensor_utils.convert_value_to_unit(1000, units.PPB, units.PPM, capabilities.carbonMonoxideMeasurement.NAME) + assert(result ~= nil, "Expected a value for supported PPB to PPM conversion") + assert(result == 1, "Expected 1 for PPB to PPM conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unit conversion with non-numeric value should return nil", + function() + local result = air_quality_sensor_utils.convert_value_to_unit("not a number", units.PPM, units.PPB, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil when value is not a number, got: " .. tostring(result)) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 9a2cbbaa89..a889663b92 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -155,18 +155,138 @@ matterManufacturer: deviceLabel: OREIN Bath Fan OLO5S vendorId: 0x1396 productId: 0x1001 - deviceProfileName: light-color-level-fan + deviceProfileName: fan-modular - id: "5014/4214" deviceLabel: Linkind Smart Light Bulb vendorId: 0x1396 productId: 0x1076 deviceProfileName: light-color-level + - id: "5014/4245" + deviceLabel: OREiN Matter smart Bathroom Fan + vendorId: 0x1396 + productId: 0x1095 + deviceProfileName: fan-modular + - id: "5014/4246" + deviceLabel: OREiN Matter smart Bathroom Fan + vendorId: 0x1396 + productId: 0x1096 + deviceProfileName: fan-modular + - id: "5014/4247" + deviceLabel: OREiN Matter smart Bathroom Fan + vendorId: 0x1396 + productId: 0x1097 + deviceProfileName: fan-modular + - id: "5014/4215" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1077 + deviceProfileName: light-color-level + - id: "5014/4273" + deviceLabel: Linkind Smart Ceiling Light + vendorId: 0x1396 + productId: 0x10B1 + deviceProfileName: light-color-level + - id: "5014/4164" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1044 + deviceProfileName: light-color-level + - id: "5014/4118" + deviceLabel: Linkind Smart Filament Bulb + vendorId: 0x1396 + productId: 0x1016 + deviceProfileName: light-color-level + - id: "5014/4116" + deviceLabel: Linkind Smart Light Bulb + vendorId: 0x1396 + productId: 0x1014 + deviceProfileName: light-color-level + - id: "5014/4165" + deviceLabel: Linkind Smart Downlight + vendorId: 0x1396 + productId: 0x1045 + deviceProfileName: light-color-level + - id: "5014/4578" + deviceLabel: Linkind Light Stick T19 + vendorId: 0x1396 + productId: 0x11E2 + deviceProfileName: light-color-level + - id: "5014/4274" + deviceLabel: Linkind Smart Ceiling Light + vendorId: 0x1396 + productId: 0x10B2 + deviceProfileName: light-color-level + - id: "5014/4642" + deviceLabel: Linkind Smart Permanent Outdoor Lights + vendorId: 0x1396 + productId: 0x1222 + deviceProfileName: light-color-level + - id: "5014/4629" + deviceLabel: Smart Outdoor String Lights + vendorId: 0x1396 + productId: 0x1215 + deviceProfileName: light-color-level + - id: "5014/4158" + deviceLabel: Linkind Smart Ceiling Fan + 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 + - 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 + productId: 0x1269 + deviceProfileName: plug-binary #Bosch Smart Home - id: "4617/12310" deviceLabel: Plug Compact [M] vendorId: 0x1209 productId: 0x3016 deviceProfileName: plug-power-energy-powerConsumption + - id: 4617/12307 + deviceLabel: "Motion Detector II [M]" + vendorId: 0x1209 + productId: 0x3013 + deviceProfileName: light-level-battery-illuminance-motion-temperature #Chengdu - id: "5218/8197" deviceLabel: Magic Cube DS001 @@ -179,6 +299,11 @@ matterManufacturer: vendorId: 0x1407 productId: 0x1088 deviceProfileName: light-color-level-illuminance-motion-1000K-15000K + - id: "5127/5121" + deviceLabel: ThirdReality Smart Night Light -T + vendorId: 0x1407 + productId: 0x1401 + deviceProfileName: light-color-level-illuminance-motion - id: "5127/4744" deviceLabel: Smart Bridge MZ1 vendorId: 0x1407 @@ -189,6 +314,11 @@ matterManufacturer: vendorId: 0x1407 productId: 0x1388 deviceProfileName: 12-button-keyboard + - id: "5127/4248" + deviceLabel: Smart Garage Door Opener + vendorId: 0x1407 + productId: 0x1098 + deviceProfileName: garage-door-battery #Edishine - id: "5458/3328" @@ -285,6 +415,11 @@ matterManufacturer: vendorId: 0x1434 productId: 0x1000 deviceProfileName: matter-bridge + - id: "5172/4352" + deviceLabel: Wi-Fi Relay + vendorId: 0x1434 + productId: 0x1100 + deviceProfileName: plug-binary #GE - id: "4921/177" @@ -467,6 +602,16 @@ matterManufacturer: vendorId: 0x1339 productId: 0x0016 deviceProfileName: light-color-level-2000K-7000K + - id: "4921/86" + deviceLabel: Cync Smart Shop Light + vendorId: 0x1339 + productId: 0x0056 + deviceProfileName: light-color-level + - id: "4921/85" + deviceLabel: Dual Socket Smart Plug + vendorId: 0x1339 + productId: 0x0055 + deviceProfileName: plug-binary #Govee - id: "4999/24740" deviceLabel: Govee Square Ceiling Light (12 inch) @@ -639,7 +784,7 @@ matterManufacturer: productId: 0x61B9 deviceProfileName: light-color-level - id: "4999/24742" - deviceLabel: Govee Ceiling Light Pro (15 inch) + deviceLabel: Govee Ceiling Light Pro vendorId: 0x1387 productId: 0x60A6 deviceProfileName: light-color-level @@ -709,7 +854,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 @@ -738,6 +883,163 @@ matterManufacturer: vendorId: 0x1387 productId: 0x6094 deviceProfileName: light-color-level + - id: "4999/24646" + deviceLabel: Govee TV Light Bar + vendorId: 0x1387 + productId: 0x6046 + deviceProfileName: light-color-level + - id: "4999/24662" + deviceLabel: Govee RGBICWW Light Bar + vendorId: 0x1387 + productId: 0x6056 + deviceProfileName: light-color-level + - id: "4999/5281" + deviceLabel: Govee Smart Bulb PAR38 + vendorId: 0x1387 + productId: 0x14A1 + deviceProfileName: light-color-level + - id: "4999/5121" + deviceLabel: Govee Smart Bulb A21 1600lm + vendorId: 0x1387 + productId: 0x1401 + deviceProfileName: light-color-level + - id: "4999/5313" + deviceLabel: Govee Edison Bulb + vendorId: 0x1387 + productId: 0x14C1 + deviceProfileName: light-color-level + - id: "4999/5312" + deviceLabel: Govee Edison Bulb + vendorId: 0x1387 + productId: 0x14C0 + deviceProfileName: light-color-level + - id: "4999/5680" + deviceLabel: Govee Lantern Floor Lamp + vendorId: 0x1387 + productId: 0x1630 + deviceProfileName: light-color-level + - id: "4999/5953" + deviceLabel: Govee Table Lamp Classic + vendorId: 0x1387 + productId: 0x1741 + deviceProfileName: light-color-level + - id: "4999/5808" + deviceLabel: Govee Floor Lamp 3 + vendorId: 0x1387 + productId: 0x16B0 + deviceProfileName: light-color-level + - id: "4999/5824" + deviceLabel: Govee Floor Lamp 3 Lite + vendorId: 0x1387 + productId: 0x16C0 + deviceProfileName: light-color-level + - id: "4999/4720" + deviceLabel: Govee Ceiling Light Ultra (21inch) + vendorId: 0x1387 + productId: 0x1270 + deviceProfileName: light-color-level + - id: "4999/24755" + deviceLabel: Govee Uplighter Floor Lamp with Nebula Effect + 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 + - 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) + vendorId: 0x1285 + productId: 0x0008 + deviceProfileName: 2-button-batteryLevel + - id: "4741/9" + deviceLabel: Hager matter 4 buttons (battery) + vendorId: 0x1285 + productId: 0x0009 + deviceProfileName: 4-button-batteryLevel + - id: "4741/5" + deviceLabel: Hager Switch 1G + vendorId: 0x1285 + productId: 0x0005 + deviceProfileName: matter-bridge + - id: "4741/6" + deviceLabel: Hager Switch 2G + vendorId: 0x1285 + productId: 0x0006 + deviceProfileName: matter-bridge + - id: "4741/7" + deviceLabel: Hager PIR 1.1M + vendorId: 0x1285 + productId: 0x0007 + deviceProfileName: matter-bridge + - id: "4741/10" + deviceLabel: Hager PIR 2.2M + vendorId: 0x1285 + productId: 0x000A + deviceProfileName: matter-bridge +# HAOJAI + - id: "5530/4113" + deviceLabel: HAOJAI Smart Switch 3-key + vendorId: 0x159A + productId: 0x1011 + deviceProfileName: 3-button-motion + - id: "5530/4098" + deviceLabel: HAOJAI Smart Switch 6-key + vendorId: 0x159A + productId: 0x1002 + deviceProfileName: 6-button-motion # Hue - id: "4107/2049" deviceLabel: Hue W 1600 A21 E26 1P NAM @@ -842,17 +1144,72 @@ matterManufacturer: vendorId: 0x1189 productId: 0x0633 deviceProfileName: plug-binary + - id: "4489/2193" + deviceLabel: SMART WIFI MATTER WALL SWITCH 2G + vendorId: 0x1189 + productId: 0x0891 + deviceProfileName: switch-binary #Ikea - id: "4476/32768" - deviceLabel: BILRESA scroll wheel + deviceLabel: BILRESA Scroll Wheel vendorId: 0x117C productId: 0x8000 deviceProfileName: ikea-scroll - id: "4476/32769" - deviceLabel: BILRESA dual button + deviceLabel: BILRESA Dual Button vendorId: 0x117C productId: 0x8001 - deviceProfileName: 2-button-battery + deviceProfileName: ikea-2-button-battery + - id: "4476/36885" + deviceLabel: KAJPLATS E12 WS Globe 800lm + vendorId: 0x117C + productId: 0x9015 + deviceProfileName: light-level-colorTemperature + - id: "4476/36880" + deviceLabel: VARMBLIXT table/wall lamp + vendorId: 0x117C + productId: 0x9010 + deviceProfileName: light-color-level + - id: "4476/36881" + deviceLabel: KAJPLATS E26 450lm + vendorId: 0x117C + productId: 0x9011 + deviceProfileName: light-level-colorTemperature + - id: "4476/36882" + deviceLabel: KAJPLATS E26 1100lm + vendorId: 0x117C + productId: 0x9012 + deviceProfileName: light-level-colorTemperature + - id: "4476/36884" + deviceLabel: KAJPLATS E26 WS 1600lm + vendorId: 0x117C + productId: 0x9014 + deviceProfileName: light-level-colorTemperature + - id: "4476/36886" + deviceLabel: KAJPLATS E26 1100lm CWS + vendorId: 0x117C + productId: 0x9016 + deviceProfileName: light-color-level + - id: "4476/36887" + deviceLabel: KAJPLATS E12 CWS Globe 800lm + vendorId: 0x117C + productId: 0x9017 + deviceProfileName: light-color-level + - id: "4476/36889" + deviceLabel: KAJPLATS E12 WS B38 CL 470lm + vendorId: 0x117C + productId: 0x9019 + deviceProfileName: light-level-colorTemperature + - id: "17526/16534" + deviceLabel: GRILLPLATS plug + vendorId: 0x4476 + productId: 0x4096 + deviceProfileName: plug-power-energy-powerConsumption + - id: "17526/16535" + deviceLabel: TOFSMYGGA + vendorId: 0x4476 + productId: 0x4097 + deviceProfileName: plug-power-energy-powerConsumption #Innovation Matters - id: "4978/1" deviceLabel: M2D Bridge @@ -957,6 +1314,16 @@ matterManufacturer: vendorId: 0x109B productId: 0x1007 deviceProfileName: 3-button + - id: "4251/4113" + deviceLabel: "Decora Smart Wi-Fi (3rd Gen) 15A Switch" + vendorId: 0x109B + productId: 0x1011 + deviceProfileName: switch-binary + - id: "4251/4112" + deviceLabel: "Decora Smart Wi-Fi (3rd Gen) 600W Dimmer" + vendorId: 0x109B + productId: 0x1010 + deviceProfileName: switch-level #LeTianPai - id: "5163/4097" deviceLabel: LeTianPai Smart Light Bulb @@ -1114,152 +1481,173 @@ matterManufacturer: deviceLabel: LIFX Supercolor (A19) vendorId: 0x1423 productId: 0x00A3 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/118" deviceLabel: LIFX Lightstrip vendorId: 0x1423 productId: 0x0076 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/221" deviceLabel: LIFX Spot vendorId: 0x1423 productId: 0x00DD - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/144" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x0090 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/216" deviceLabel: LIFX Candle Color (B10) vendorId: 0x1423 productId: 0x00D8 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/225" deviceLabel: LIFX PAR38 vendorId: 0x1423 productId: 0x00E1 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/186" deviceLabel: LIFX Candle Color vendorId: 0x1423 productId: 0x00BA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/202" deviceLabel: LIFX Ceiling 13x26 vendorId: 0x1423 productId: 0x00CA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/143" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x008F - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/166" deviceLabel: LIFX Supercolour (BR30) vendorId: 0x1423 productId: 0x00A6 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/167" deviceLabel: LIFX Downlight vendorId: 0x1423 productId: 0x00A7 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/207" deviceLabel: LIFX Everyday Lightstrip vendorId: 0x1423 productId: 0x00CF - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/222" deviceLabel: LIFX Path (Round) vendorId: 0x1423 productId: 0x00DE - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/203" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x00CB - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/218" deviceLabel: LIFX Tube vendorId: 0x1423 productId: 0x00DA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/214" deviceLabel: LIFX Permanent Outdoor vendorId: 0x1423 productId: 0x00D6 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/117" deviceLabel: LIFX Lightstrip vendorId: 0x1423 productId: 0x0075 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/223" deviceLabel: LIFX Downlight (6 Retro Downlight) vendorId: 0x1423 productId: 0x00DF - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/224" deviceLabel: LIFX Downlight (90mm Downlight) vendorId: 0x1423 productId: 0x00E0 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/204" deviceLabel: LIFX String vendorId: 0x1423 productId: 0x00CC - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/206" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x00CE - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/164" deviceLabel: LIFX Supercolor (BR30) vendorId: 0x1423 productId: 0x00A4 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/120" deviceLabel: LIFX Beam vendorId: 0x1423 productId: 0x0078 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/208" deviceLabel: LIFX Everyday Lightstrip vendorId: 0x1423 productId: 0x00D0 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/165" deviceLabel: LIFX Supercolour (A19) vendorId: 0x1423 productId: 0x00A5 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/142" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x008E - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/141" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x008D - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/177" deviceLabel: LIFX Ceiling vendorId: 0x1423 productId: 0x00B1 - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/170" deviceLabel: LIFX Supercolour (A21) vendorId: 0x1423 productId: 0x00AA - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level - id: "5155/205" deviceLabel: LIFX Neon vendorId: 0x1423 productId: 0x00CD - deviceProfileName: light-level-colorTemperature + deviceProfileName: light-color-level + - id: "5155/265" + deviceLabel: Ceiling 13 + vendorId: 0x1423 + productId: 0x0109 + deviceProfileName: light-color-level + - id: "5155/266" + deviceLabel: LIFX Ceiling 13 + vendorId: 0x1423 + productId: 0x010A + deviceProfileName: light-color-level + - id: "5155/267" + deviceLabel: LIFX Mirror + vendorId: 0x1423 + productId: 0x010B + deviceProfileName: light-color-level + - id: "5155/268" + deviceLabel: LIFX Mirror + vendorId: 0x1423 + productId: 0x010C + deviceProfileName: light-color-level + #LG - id: "4142/8784" deviceLabel: LG Smart Button (1 Button) @@ -1339,12 +1727,23 @@ matterManufacturer: vendorId: 0x137F productId: 0x027B deviceProfileName: plug-binary +#Netatmo + - id: "4129/8" + deviceLabel: Netatmo Thermo Hub + vendorId: 0x1021 + productId: 0x0008 + deviceProfileName: matter-bridge #Onvis - id: "5181/4097" deviceLabel: Onvis Smart Plug S4EU 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 @@ -1411,6 +1810,431 @@ matterManufacturer: vendorId: 0x1189 productId: 0x0A7E deviceProfileName: light-level + - id: "4489/2725" + deviceLabel: SMART MAT PAR16 RGBW GU10 + vendorId: 0x1189 + productId: 0x0AA5 + deviceProfileName: light-color-level + - id: "4489/2766" + deviceLabel: SMART MAT B40 RGBW 827 FR E14 + vendorId: 0x1189 + productId: 0x0ACE + deviceProfileName: light-color-level + - id: "4489/2765" + deviceLabel: SMART MAT P40 RGBW 827 FR E14 + vendorId: 0x1189 + productId: 0x0ACD + deviceProfileName: light-color-level + - id: "4489/2764" + deviceLabel: SMART MAT G95 RGBW 827 FR E27 + vendorId: 0x1189 + productId: 0x0ACC + deviceProfileName: light-color-level + - id: "4489/2774" + deviceLabel: SMART MAT A40 FIL RGBW 827 E27 + vendorId: 0x1189 + productId: 0x0AD6 + deviceProfileName: light-color-level + - id: "4489/2775" + deviceLabel: SMART MAT E40 FIL RGBW 827 E27 + vendorId: 0x1189 + productId: 0x0AD7 + deviceProfileName: light-color-level + - id: "4489/2776" + deviceLabel: SMART MAT G40 FIL RGBW 827 E27 + vendorId: 0x1189 + productId: 0x0AD8 + deviceProfileName: light-color-level + - id: "4489/2837" + deviceLabel: SMART MAT A75 RGBW 827 FR E27 + vendorId: 0x1189 + productId: 0x0B15 + deviceProfileName: light-color-level + - id: "4489/2713" + deviceLabel: SMART MAT A60 TW 827 FR E27 + vendorId: 0x1189 + productId: 0x0A99 + deviceProfileName: light-level-colorTemperature + - id: "4489/2711" + deviceLabel: SMART MAT A100 TW 827 FR E27 + vendorId: 0x1189 + productId: 0x0A97 + deviceProfileName: light-level-colorTemperature + - id: "4489/2716" + deviceLabel: SMART MAT P40 TW 827 FR E14 + vendorId: 0x1189 + productId: 0x0A9C + deviceProfileName: light-level-colorTemperature + - id: "4489/2726" + deviceLabel: SMART MAT PAR16 TW 827 GU10 + vendorId: 0x1189 + productId: 0x0AA6 + deviceProfileName: light-level-colorTemperature + - id: "4489/2678" + deviceLabel: SMART MAT E60 DIM FIL 827 E27 + vendorId: 0x1189 + productId: 0x0A76 + deviceProfileName: light-level + - id: "4489/2676" + deviceLabel: SMART MAT G60 DIM FIL 827 E27 + vendorId: 0x1189 + productId: 0x0A74 + deviceProfileName: light-level + - id: "4489/2687" + deviceLabel: SMART MAT B40 DIM FIL 827 E14 + vendorId: 0x1189 + productId: 0x0A7F + deviceProfileName: light-level + - id: "4489/2685" + deviceLabel: SMART MAT E53 DIM FILGD 824 E27 + vendorId: 0x1189 + productId: 0x0A7D + deviceProfileName: light-level + - id: "4489/2684" + deviceLabel: SMART MAT G53 DIM FILGD 824 E27 + vendorId: 0x1189 + productId: 0x0A7C + deviceProfileName: light-level + - id: "4489/2681" + deviceLabel: SMART MAT A60 DIM FIL 827 E27 + vendorId: 0x1189 + productId: 0x0A79 + deviceProfileName: light-level + - id: "4489/2682" + deviceLabel: SMART MAT P40 DIM FIL 827 E14 + vendorId: 0x1189 + productId: 0x0A7A + deviceProfileName: light-level + - id: "4489/2727" + deviceLabel: SMART MAT PAR16 DIM GU10 + vendorId: 0x1189 + productId: 0x0AA7 + deviceProfileName: light-level + - id: "4489/2712" + deviceLabel: SMART MAT G95 100 TW 827 E27 + vendorId: 0x1189 + productId: 0x0A98 + deviceProfileName: light-level-colorTemperature + - id: "4489/2836" + deviceLabel: SMART MAT A75 TW 827 FR E27 + vendorId: 0x1189 + productId: 0x0B14 + deviceProfileName: light-level-colorTemperature + - id: "4489/2514" + deviceLabel: SMART MAT ORBIS DL 200 TW WT + vendorId: 0x1189 + productId: 0x09D2 + deviceProfileName: light-color-level + - id: "4489/2518" + deviceLabel: SMART MAT ORBIS DL 400 TW WT + vendorId: 0x1189 + productId: 0x09D6 + deviceProfileName: light-color-level + - id: "4489/2519" + deviceLabel: SMART MAT ORBIS DL SQ400 TW WT + vendorId: 0x1189 + productId: 0x09D7 + deviceProfileName: light-color-level + - id: "4489/2763" + deviceLabel: SMART MAT A60 RGBW 827 FR B22D + vendorId: 0x1189 + productId: 0x0ACB + deviceProfileName: light-color-level + - id: "4489/2756" + deviceLabel: SMART MAT A100 RGBW 827 FR E27 + vendorId: 0x1189 + productId: 0x0AC4 + deviceProfileName: light-color-level + - id: "4489/3027" + deviceLabel: SMART MAT DECORWALLSWAN300X150TW + vendorId: 0x1189 + productId: 0x0BD3 + deviceProfileName: light-level-colorTemperature + - id: "4489/2994" + deviceLabel: SMART MAT PLANON FL 120X30 TW + vendorId: 0x1189 + productId: 0x0BB2 + deviceProfileName: light-level-colorTemperature + - id: "4489/3032" + deviceLabel: SMART WIFI MAT BATH W400 IP44 TW + vendorId: 0x1189 + productId: 0x0BD8 + deviceProfileName: light-level-colorTemperature + - id: "4489/3026" + deviceLabel: SMART WIFI MAT WAVE300X300IP44TW + vendorId: 0x1189 + productId: 0x0BD2 + deviceProfileName: light-level-colorTemperature + - id: "4489/2957" + deviceLabel: SMART WIFI MAT TRACKSP OSAKA TW + vendorId: 0x1189 + productId: 0x0B8D + deviceProfileName: light-level-colorTemperature + - id: "4489/3025" + deviceLabel: SMART WIFI MAT CYLINDER 450 TW + vendorId: 0x1189 + productId: 0x0BD1 + deviceProfileName: light-level-colorTemperature + - id: "4489/2969" + deviceLabel: SMART MAT PLANON PLUS 120X30TW + vendorId: 0x1189 + productId: 0x0B99 + deviceProfileName: light-level-colorTemperature + - id: "4489/3028" + deviceLabel: SMART MAT DISC 400 IP44 TW + vendorId: 0x1189 + productId: 0x0BD4 + deviceProfileName: light-level-colorTemperature + - id: "4489/2971" + deviceLabel: SMART MAT PLANON FL 80X10TW + vendorId: 0x1189 + productId: 0x0B9B + deviceProfileName: light-level-colorTemperature + - id: "4489/3014" + deviceLabel: SMART MAT PLANONFLSPARK45X45TW + vendorId: 0x1189 + productId: 0x0BC6 + deviceProfileName: light-level-colorTemperature + - id: "4489/3023" + deviceLabel: SMART WIFI MAT MAGNET 600X300 TW + vendorId: 0x1189 + productId: 0x0BCF + deviceProfileName: light-level-colorTemperature + - id: "4489/3021" + deviceLabel: SMART WIFI MAT AQUA280X160IP44TW + vendorId: 0x1189 + productId: 0x0BCD + deviceProfileName: light-level-colorTemperature + - id: "4489/3035" + deviceLabel: SMART WIFI MAT DUPLO 300 IP44 TW + vendorId: 0x1189 + productId: 0x0BDB + deviceProfileName: light-level-colorTemperature + - id: "4489/3024" + deviceLabel: SMART WIFI MAT DUPLO 580 IP44 TW + vendorId: 0x1189 + productId: 0x0BD0 + deviceProfileName: light-level-colorTemperature + - id: "4489/2998" + deviceLabel: SMART WIFI MAT DL SLIM 120 TW + vendorId: 0x1189 + productId: 0x0BB6 + deviceProfileName: light-level-colorTemperature + - id: "4489/3020" + deviceLabel: SMART MATDECORWALLTWIST230X127TW + vendorId: 0x1189 + productId: 0x0BCC + deviceProfileName: light-level-colorTemperature + - id: "4489/3049" + deviceLabel: SMART WIFI MAT TRACKSP CIRCLE TW + vendorId: 0x1189 + productId: 0x0BE9 + deviceProfileName: light-level-colorTemperature + - id: "4489/3029" + deviceLabel: SMART WIFI MAT DUPLO 450 IP44 TW + vendorId: 0x1189 + productId: 0x0BD5 + deviceProfileName: light-level-colorTemperature + - id: "4489/2991" + deviceLabel: SMART MAT PLANON PLUS 60X60 TW + vendorId: 0x1189 + productId: 0x0BAF + deviceProfileName: light-level-colorTemperature + - id: "4489/2985" + deviceLabel: SMART MAT PLANON PLUS 30X30 TW + vendorId: 0x1189 + productId: 0x0BA9 + deviceProfileName: light-level-colorTemperature + - id: "4489/3033" + deviceLabel: SMART WIFI MAT BATH W300 IP44 TW + vendorId: 0x1189 + productId: 0x0BD9 + deviceProfileName: light-level-colorTemperature + - id: "4489/2999" + deviceLabel: SMART WIFI MAT DL SLIM 225 TW + vendorId: 0x1189 + productId: 0x0BB7 + deviceProfileName: light-level-colorTemperature + - id: "4489/3037" + deviceLabel: SMART WIFI MAT MAGNET 300X300 TW + vendorId: 0x1189 + productId: 0x0BDD + deviceProfileName: light-level-colorTemperature + - id: "4489/2990" + deviceLabel: SMART MAT PLANON PLUS 60X30 TW + vendorId: 0x1189 + productId: 0x0BAE + deviceProfileName: light-level-colorTemperature + - id: "4489/3047" + deviceLabel: SMART WIFI MAT TRACKSP OSAKA TW + vendorId: 0x1189 + productId: 0x0BE7 + deviceProfileName: light-level-colorTemperature + - id: "4489/3031" + deviceLabel: SMART WIFI MAT MAGNET 600X300 TW + vendorId: 0x1189 + productId: 0x0BD7 + deviceProfileName: light-level-colorTemperature + - id: "4489/3034" + deviceLabel: SMART WIFI MAT DISC 300 IP44 TW + vendorId: 0x1189 + productId: 0x0BDA + deviceProfileName: light-level-colorTemperature + - id: "4489/3030" + deviceLabel: SMART WIFI MAT MAGNET 450X450 TW + vendorId: 0x1189 + productId: 0x0BD6 + deviceProfileName: light-level-colorTemperature + - id: "4489/3022" + deviceLabel: SMART WIFI MAT AQUA200X200IP44TW + vendorId: 0x1189 + productId: 0x0BCE + deviceProfileName: light-level-colorTemperature + - id: "4489/2993" + deviceLabel: SMART MAT PLANON FL 30X30 TW + vendorId: 0x1189 + productId: 0x0BB1 + deviceProfileName: light-level-colorTemperature + - id: "4489/3048" + deviceLabel: SMART WIFI MAT TRACKSP CIRCLE TW + vendorId: 0x1189 + productId: 0x0BE8 + deviceProfileName: light-level-colorTemperature + - id: "4489/2997" + deviceLabel: SMART WIFI MAT DL SLIM 85 TW WT + vendorId: 0x1189 + productId: 0x0BB5 + deviceProfileName: light-level-colorTemperature + - id: "4489/2949" + deviceLabel: SMART WIFI MAT SPIRAL 500 TW + vendorId: 0x1189 + productId: 0x0B85 + deviceProfileName: light-level-colorTemperature + - id: "4489/3036" + deviceLabel: SMART WFMTDECORWALLSWAN200X200TW + vendorId: 0x1189 + productId: 0x0BDC + deviceProfileName: light-level-colorTemperature + - id: "4489/2995" + deviceLabel: SMART MAT PLANON FL 120X10 TW + vendorId: 0x1189 + productId: 0x0BB3 + deviceProfileName: light-level-colorTemperature + - id: "4489/2987" + deviceLabel: SMART MAT PLANON PLUS 45X45 TW + vendorId: 0x1189 + productId: 0x0BAB + deviceProfileName: light-level-colorTemperature + - id: "4489/3018" + deviceLabel: SMART WFMTDECORWALLWOOD210X110TW + vendorId: 0x1189 + productId: 0x0BCA + deviceProfileName: light-level-colorTemperature + - id: "4489/3019" + deviceLabel: SMART MATDECORWALLTWIST230X127TW + vendorId: 0x1189 + productId: 0x0BCB + deviceProfileName: light-level-colorTemperature + - id: "4489/3055" + deviceLabel: SMART WIFI MAT FLOOD 50W RGBW + vendorId: 0x1189 + productId: 0x0BEF + deviceProfileName: light-color-level + - id: "4489/2970" + deviceLabel: SMART MAT PLANON PLUS60X60RGBTW + vendorId: 0x1189 + productId: 0x0B9A + deviceProfileName: light-color-level + - id: "4489/3057" + deviceLabel: SMART WIFI MAT FLOOD 20W RGBW + vendorId: 0x1189 + productId: 0x0BF1 + deviceProfileName: light-color-level + - id: "4489/2973" + deviceLabel: SMART MAT PLANON PLUS45X45RGBW + vendorId: 0x1189 + productId: 0x0B9D + deviceProfileName: light-color-level + - id: "4489/2974" + deviceLabel: SMART MAT PLANON PLUS60X30RGBW + vendorId: 0x1189 + productId: 0x0B9E + deviceProfileName: light-color-level + - id: "4489/3059" + deviceLabel: SMART WIFI MAT CLARIA 490RGBTW + vendorId: 0x1189 + productId: 0x0BF3 + deviceProfileName: light-color-level + - id: "4489/3058" + deviceLabel: SMART WIFI MAT FLOOD 30W RGBW + vendorId: 0x1189 + productId: 0x0BF2 + deviceProfileName: light-color-level + - id: "4489/2972" + deviceLabel: SMART MAT PLANON PLUS60X60RGBW + vendorId: 0x1189 + productId: 0x0B9C + deviceProfileName: light-color-level + - id: "4489/2984" + deviceLabel: SMART MAT PLANON PLUS120X30RGBW + vendorId: 0x1189 + productId: 0x0BA8 + deviceProfileName: light-color-level + - id: "4489/3001" + deviceLabel: SMART WIFI MAT SP170 110DEGRGBTW + vendorId: 0x1189 + productId: 0x0BB9 + deviceProfileName: light-color-level + - id: "4489/2992" + deviceLabel: SMART MAT PLANON PLUS30X30RGBW + vendorId: 0x1189 + productId: 0x0BB0 + deviceProfileName: light-color-level + - id: "4489/3056" + deviceLabel: SMART WIFI MAT FLOOD 10W RGBW + vendorId: 0x1189 + productId: 0x0BF0 + deviceProfileName: light-color-level + - id: "4489/3016" + deviceLabel: SMART OUTD MAT BEAMADJWALL RGBTW + vendorId: 0x1189 + productId: 0x0BC8 + deviceProfileName: light-color-level + - id: "4489/3000" + deviceLabel: SMART WIFI MAT SP 86 100DEGRGBTW + vendorId: 0x1189 + productId: 0x0BB8 + deviceProfileName: light-color-level + - id: "4489/3003" + deviceLabel: SMART WIFI MAT STEA485RD RGBTW + vendorId: 0x1189 + productId: 0x0BBB + deviceProfileName: light-color-level + - id: "4489/2963" + deviceLabel: SMART WIFI MAT FLOOD 100W RGBW + vendorId: 0x1189 + productId: 0x0B93 + deviceProfileName: light-color-level + - id: "4489/2194" + deviceLabel: SMART WIFI MATTER WALL SWITCH 1G + vendorId: 0x1189 + productId: 0x0892 + deviceProfileName: switch-binary + - id: "4489/4097" + deviceLabel: OSRAM PLANON SLIM + vendorId: 0x1189 + productId: 0x1001 + deviceProfileName: light-color-level + - id: "4489/4573" + deviceLabel: OSRAM IRIS + vendorId: 0x1189 + productId: 0x11DD + deviceProfileName: light-color-level + - id: "4489/4572" + deviceLabel: OSRAM GISELA + vendorId: 0x1189 + productId: 0x11DC + deviceProfileName: light-color-level #Shelly - id: "5264/1" deviceLabel: Shelly Plug S MTR Gen3 @@ -1606,11 +2430,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 @@ -3469,6 +4288,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 @@ -3479,6 +4308,44 @@ matterManufacturer: vendorId: 0x1344 productId: 0x041E deviceProfileName: matter-bridge +#Tapo + - id: "5010/269" + deviceLabel: Tapo Smart Power Strip + vendorId: 0x1392 + productId: 0x010D + deviceProfileName: plug-binary + - id: "5010/262" + deviceLabel: Mini Smart Wi-Fi Plug Energy Monitoring Matter + vendorId: 0x1392 + productId: 0x0106 + deviceProfileName: plug-power-energy-powerConsumption + - id: "5010/276" + deviceLabel: Smart WiFi Outdoor Plug + vendorId: 0x1392 + productId: 0x0114 + deviceProfileName: plug-energy-powerConsumption +#GIANT LIGHTING + - id: "5707/1" + deviceLabel: Floor Lamp + 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 + - id: "5732/1536" + deviceLabel: Utilitech 17 Smart Flush Mount - Full Color + 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: @@ -3566,6 +4433,11 @@ matterGeneric: deviceTypes: - id: 0x0110 # Mounted Dimmable Load Control deviceProfileName: switch-level + - id: "matter/irrigation-system" + deviceLabel: Matter Irrigation System + deviceTypes: + - id: 0x0040 # Irrigation System + deviceProfileName: irrigation-system - id: "matter/water-valve" deviceLabel: Matter Water Valve deviceTypes: @@ -3604,7 +4476,7 @@ matterGeneric: deviceTypes: - id: 0x002B # Fan - id: 0x0100 # OnOff Light - deviceProfileName: fan-modular + deviceProfileName: fan-modular - id: "matter/dimmable/fan/light" deviceLabel: Matter Dimmable Fan Light deviceTypes: diff --git a/drivers/SmartThings/matter-switch/profiles/camera.yml b/drivers/SmartThings/matter-switch/profiles/camera.yml index 7a46099cf6..1f911a9e10 100644 --- a/drivers/SmartThings/matter-switch/profiles/camera.yml +++ b/drivers/SmartThings/matter-switch/profiles/camera.yml @@ -47,6 +47,9 @@ components: - id: motionSensor version: 1 optional: true + - id: vision.clipAnalysis + version: 1 + optional: true - id: firmwareUpdate version: 1 - id: refresh @@ -131,6 +134,9 @@ deviceConfig: - component: main capability: motionSensor version: 1 + - component: main + capability: videoCapture2 + version: 1 automation: conditions: - component: main diff --git a/drivers/SmartThings/matter-switch/profiles/garage-door-battery.yml b/drivers/SmartThings/matter-switch/profiles/garage-door-battery.yml new file mode 100644 index 0000000000..bbab3c16ad --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/garage-door-battery.yml @@ -0,0 +1,14 @@ +name: garage-door-battery +components: +- id: main + capabilities: + - id: doorControl + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: GarageDoor diff --git a/drivers/SmartThings/matter-switch/profiles/ikea-2-button-battery.yml b/drivers/SmartThings/matter-switch/profiles/ikea-2-button-battery.yml new file mode 100644 index 0000000000..011ecc1683 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/ikea-2-button-battery.yml @@ -0,0 +1,56 @@ +name: ikea-2-button-battery +components: + - id: main + label: Button 1 + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button2 + label: Button 2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +deviceConfig: + icons: + - group: main + iconUrl: 'icon://button_multi' + dashboard: + states: + - component: main + capability: button + version: 1 + detailView: + - component: main + capability: button + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + - component: button2 + capability: button + version: 1 + automation: + conditions: + - component: main + capability: button + version: 1 + - component: main + capability: battery + version: 1 + - component: button2 + capability: button + version: 1 + actions: [] diff --git a/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml b/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml index 4dc77d026d..1793ae209e 100644 --- a/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml +++ b/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml @@ -5,6 +5,8 @@ components: capabilities: - id: button version: 1 + - id: knob + version: 1 - id: battery version: 1 - id: firmwareUpdate @@ -12,18 +14,32 @@ components: - id: refresh version: 1 categories: - - name: RemoteController + - name: Button - id: group2 label: Group 2 capabilities: - id: button version: 1 + - id: knob + version: 1 categories: - - name: RemoteController + - name: Button - id: group3 label: Group 3 capabilities: - id: button version: 1 + - id: knob + version: 1 categories: - - name: RemoteController + - name: Button +preferences: + - preferenceId: knobSensitivityGroup1 + explicit: true + - preferenceId: knobSensitivityGroup2 + explicit: true + - preferenceId: knobSensitivityGroup3 + explicit: true +metadata: + mnmn: SmartThingsEdge + vid: ikea-scroll diff --git a/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml b/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml new file mode 100644 index 0000000000..15896fbfee --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/irrigation-system.yml @@ -0,0 +1,26 @@ +name: irrigation-system +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: level + version: 1 + config: + values: + - key: "level.value" + range: [0, 100] + optional: true + - id: flowMeasurement + version: 1 + optional: true + - id: operationalState + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Irrigation + diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-1800K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-1800K-6500K.yml index 58c8e0fca6..a0f5196b12 100755 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-1800K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-1800K-6500K.yml @@ -11,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 1800, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-2000K-7000K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-2000K-7000K.yml index 4772e22f66..7424d241e2 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-2000K-7000K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-2000K-7000K.yml @@ -11,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2000, 7000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-2200K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-2200K-6500K.yml index 4977423135..a098cdd06f 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-2200K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-2200K-6500K.yml @@ -11,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-2700K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-2700K-6500K.yml index dbef511bbb..1b469ae8e8 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-2700K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-2700K-6500K.yml @@ -11,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml index 2fabc23bd7..1b1129e8be 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml @@ -1,3 +1,4 @@ +# Deprecated: do not use this profile for device fingerprinting. name: light-color-level-fan components: - id: main @@ -10,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: fanMode diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion-1000K-15000K.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion-1000K-15000K.yml index dd48e4e81a..fb7a4fca94 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion-1000K-15000K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion-1000K-15000K.yml @@ -7,12 +7,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 1000, 15000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: motionSensor diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion.yml index 999e64a047..50f68f60fc 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level-illuminance-motion.yml @@ -6,12 +6,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: motionSensor diff --git a/drivers/SmartThings/matter-switch/profiles/light-color-level.yml b/drivers/SmartThings/matter-switch/profiles/light-color-level.yml index 572686ffdd..2f0f673bee 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-color-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-color-level.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-2-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-2-button.yml index 7c8b60ef56..6a4fbcc9d0 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-2-button.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-2-button.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-3-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-3-button.yml index 59600efd72..9e3106d9de 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-3-button.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-3-button.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-4-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-4-button.yml index b49b7f2254..43bcec5955 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-4-button.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-4-button.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-5-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-5-button.yml index ee55a6a394..2b98b9784d 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-5-button.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-5-button.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-6-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-6-button.yml index 805c97763e..230b2ea341 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-6-button.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-6-button.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-7-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-7-button.yml index 5cd1666a5f..13b3ee9443 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-7-button.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-7-button.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-8-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-8-button.yml index 4636359e92..a2dc732257 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-8-button.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-8-button.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-ColorTemperature-1500-9000k.yml b/drivers/SmartThings/matter-switch/profiles/light-level-ColorTemperature-1500-9000k.yml index f19e55ec9c..effe5f93b6 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-ColorTemperature-1500-9000k.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-ColorTemperature-1500-9000k.yml @@ -11,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 1500, 9000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-battery-illuminance-motion-temperature.yml b/drivers/SmartThings/matter-switch/profiles/light-level-battery-illuminance-motion-temperature.yml new file mode 100644 index 0000000000..fb1cd099a4 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/light-level-battery-illuminance-motion-temperature.yml @@ -0,0 +1,32 @@ +name: light-level-battery-illuminance-motion-temperature +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: motionSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - preferenceId: tempOffset + explicit: true + diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-button.yml b/drivers/SmartThings/matter-switch/profiles/light-level-button.yml index 9fc53f642b..8334e32feb 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-button.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-button.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2200K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2200K-6500K.yml index 79d6556485..2541fddc09 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2200K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2200K-6500K.yml @@ -11,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2700K-6500K.yml b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2700K-6500K.yml index 75b2bd488c..7c845e8238 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2700K-6500K.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2700K-6500K.yml @@ -11,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2710k-6500k.yml b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2710k-6500k.yml index b49179036f..5ccc67ab4c 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2710k-6500k.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature-2710k-6500k.yml @@ -11,12 +11,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2710, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature.yml b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature.yml index 4d130281d7..5681a97e9e 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-colorTemperature.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/light-level-energy-powerConsumption.yml index 03963ccbd2..b2ddcc0a60 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-energy-powerConsumption.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-energy-powerConsumption.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: energyMeter version: 1 - id: powerConsumptionReport diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-motion.yml b/drivers/SmartThings/matter-switch/profiles/light-level-motion.yml index bdea457c21..54ce30abdf 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-motion.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-motion.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: motionSensor version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-power-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/light-level-power-energy-powerConsumption.yml index f6c45ed1f7..31c6d44ee1 100755 --- a/drivers/SmartThings/matter-switch/profiles/light-level-power-energy-powerConsumption.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-power-energy-powerConsumption.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: energyMeter diff --git a/drivers/SmartThings/matter-switch/profiles/light-level-power.yml b/drivers/SmartThings/matter-switch/profiles/light-level-power.yml index 23625ada16..86d61c0345 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level-power.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level-power.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/light-level.yml b/drivers/SmartThings/matter-switch/profiles/light-level.yml index e266f497c9..3b286f8262 100644 --- a/drivers/SmartThings/matter-switch/profiles/light-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/light-level.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml b/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml new file mode 100644 index 0000000000..dfe052043c --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/motion-illuminance.yml @@ -0,0 +1,14 @@ +name: motion-illuminance +components: +- id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor diff --git a/drivers/SmartThings/matter-switch/profiles/plug-level-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/plug-level-energy-powerConsumption.yml index 86bd861bce..4ae29d2320 100644 --- a/drivers/SmartThings/matter-switch/profiles/plug-level-energy-powerConsumption.yml +++ b/drivers/SmartThings/matter-switch/profiles/plug-level-energy-powerConsumption.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: energyMeter version: 1 - id: powerConsumptionReport diff --git a/drivers/SmartThings/matter-switch/profiles/plug-level-power-energy-powerConsumption.yml b/drivers/SmartThings/matter-switch/profiles/plug-level-power-energy-powerConsumption.yml index 17e7d6b7a0..477fb8b769 100644 --- a/drivers/SmartThings/matter-switch/profiles/plug-level-power-energy-powerConsumption.yml +++ b/drivers/SmartThings/matter-switch/profiles/plug-level-power-energy-powerConsumption.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: energyMeter diff --git a/drivers/SmartThings/matter-switch/profiles/plug-level-power.yml b/drivers/SmartThings/matter-switch/profiles/plug-level-power.yml index d175930a92..8bb1c56361 100644 --- a/drivers/SmartThings/matter-switch/profiles/plug-level-power.yml +++ b/drivers/SmartThings/matter-switch/profiles/plug-level-power.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/plug-level.yml b/drivers/SmartThings/matter-switch/profiles/plug-level.yml index 0d888b843f..90fa2ece47 100644 --- a/drivers/SmartThings/matter-switch/profiles/plug-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/plug-level.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/switch-color-level.yml b/drivers/SmartThings/matter-switch/profiles/switch-color-level.yml index f1f9e78438..c684f632c9 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-color-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-color-level.yml @@ -6,12 +6,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-switch/profiles/switch-level-colorTemperature.yml b/drivers/SmartThings/matter-switch/profiles/switch-level-colorTemperature.yml index 42e3ef6257..9cba419d02 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-level-colorTemperature.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-level-colorTemperature.yml @@ -6,12 +6,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/switch-level.yml b/drivers/SmartThings/matter-switch/profiles/switch-level.yml index 8f3b9f5e5c..827fcdd898 100644 --- a/drivers/SmartThings/matter-switch/profiles/switch-level.yml +++ b/drivers/SmartThings/matter-switch/profiles/switch-level.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-switch/profiles/window-covering.yml b/drivers/SmartThings/matter-switch/profiles/window-covering.yml new file mode 100644 index 0000000000..b588b41ec9 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/window-covering.yml @@ -0,0 +1,21 @@ +name: window-covering +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadePreset + version: 1 + - id: windowShadeLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +preferences: + - preferenceId: presetPosition + explicit: true + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/init.lua index fb7a38e63b..12419a9a26 100644 --- a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/init.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/init.lua @@ -3,6 +3,7 @@ local cluster_base = require "st.matter.cluster_base" local DescriptorServerAttributes = require "embedded_clusters.Descriptor.server.attributes" +local DescriptorTypes = require "embedded_clusters.Descriptor.types" local Descriptor = {} @@ -11,9 +12,11 @@ Descriptor.NAME = "Descriptor" Descriptor.server = {} Descriptor.client = {} Descriptor.server.attributes = DescriptorServerAttributes:set_parent_cluster(Descriptor) +Descriptor.types = DescriptorTypes function Descriptor:get_attribute_by_id(attr_id) local attr_id_map = { + [0x0000] = "DeviceTypeList", [0x0003] = "PartsList", } local attr_name = attr_id_map[attr_id] @@ -33,6 +36,7 @@ function Descriptor:get_server_command_by_id(command_id) end Descriptor.attribute_direction_map = { + ["DeviceTypeList"] = "server", ["PartsList"] = "server", } diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/DeviceTypeList.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/DeviceTypeList.lua new file mode 100644 index 0000000000..7bd42e15eb --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/server/attributes/DeviceTypeList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local DeviceTypeList = { + ID = 0x0000, + NAME = "DeviceTypeList", + base_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.Descriptor.types.DeviceTypeStruct", +} + +function DeviceTypeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, DeviceTypeList.element_type) + end +end + +function DeviceTypeList:new_value(...) + local o = self.base_type(table.unpack({ ... })) + return o +end + +function DeviceTypeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function DeviceTypeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function DeviceTypeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function DeviceTypeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function DeviceTypeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(DeviceTypeList, { __call = DeviceTypeList.new_value, __index = DeviceTypeList.base_type }) +return DeviceTypeList + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/DeviceTypeStruct.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/DeviceTypeStruct.lua new file mode 100644 index 0000000000..0cb771558d --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/DeviceTypeStruct.lua @@ -0,0 +1,82 @@ +-- Copyright 2026 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local DeviceTypeStruct = {} +local new_mt = StructureABC.new_mt({ NAME = "DeviceTypeStruct", ID = data_types.name_to_id_map["Structure"] }) + +DeviceTypeStruct.field_defs = { + { + name = "device_type", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint32", + }, + { + name = "revision", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, +} + +DeviceTypeStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do + -- Note: _idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +DeviceTypeStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = DeviceTypeStruct.init +new_mt.__index.serialize = DeviceTypeStruct.serialize + +DeviceTypeStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(DeviceTypeStruct, new_mt) + +return DeviceTypeStruct + diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/init.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/init.lua new file mode 100644 index 0000000000..8ecaa987fd --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/Descriptor/types/init.lua @@ -0,0 +1,17 @@ +-- Copyright 2026 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.Descriptor.types." .. key) + end + return types_mt.__types_cache[key] +end + +local DescriptorTypes = {} + +setmetatable(DescriptorTypes, types_mt) + +return DescriptorTypes diff --git a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua index a4c58a3646..5452b287ac 100644 --- a/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua +++ b/drivers/SmartThings/matter-switch/src/embedded_clusters/ElectricalEnergyMeasurement/types/EnergyMeasurementStruct.lua @@ -42,6 +42,20 @@ EnergyMeasurementStruct.field_defs = { is_optional = true, data_type = require "st.matter.data_types.Uint64", }, + { + name = "apparent_energy", + field_id = 5, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Int64", + }, + { + name = "reactive_energy", + field_id = 6, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Int64", + }, } EnergyMeasurementStruct.init = function(cls, tbl) @@ -49,10 +63,10 @@ EnergyMeasurementStruct.init = function(cls, tbl) o.elements = {} o.num_elements = 0 setmetatable(o, new_mt) - for idx, field_def in ipairs(cls.field_defs) do + for _idx, field_def in ipairs(cls.field_defs) do if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then error("Missing non optional or non_nullable field: " .. field_def.name) - else + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) o.elements[field_def.name].field_id = field_def.field_id o.num_elements = o.num_elements + 1 @@ -71,7 +85,7 @@ new_mt.__index.serialize = EnergyMeasurementStruct.serialize EnergyMeasurementStruct.augment_type = function(self, val) local elems = {} local num_elements = 0 - for _, v in pairs(val.elements) do + for _, v in pairs(val.elements or {}) do for _, field_def in ipairs(self.field_defs) do if field_def.field_id == v.field_id and field_def.is_nullable and @@ -79,11 +93,11 @@ EnergyMeasurementStruct.augment_type = function(self, val) elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) num_elements = num_elements + 1 elseif field_def.field_id == v.field_id and not - (field_def.is_optional and v.value == nil) then + (field_def.is_optional and v.value == nil and v.elements == nil) then elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) num_elements = num_elements + 1 if field_def.element_type ~= nil then - for i, e in ipairs(elems[field_def.name].elements) do + for i, e in ipairs(elems[field_def.name].elements or {}) do elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) end end diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 5a834c0385..adebbb0ec1 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -8,15 +8,14 @@ local clusters = require "st.matter.clusters" local log = require "log" local version = require "version" local cfg = require "switch_utils.device_configuration" +local button_cfg = cfg.ButtonCfg local device_cfg = cfg.DeviceCfg local switch_cfg = cfg.SwitchCfg -local button_cfg = cfg.ButtonCfg local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" local attribute_handlers = require "switch_handlers.attribute_handlers" local event_handlers = require "switch_handlers.event_handlers" local capability_handlers = require "switch_handlers.capability_handlers" -local embedded_cluster_utils = require "switch_utils.embedded_cluster_utils" -- Include driver-side definitions when lua libs api version is < 11 if version.api < 11 then @@ -30,6 +29,11 @@ if version.api < 16 then clusters.Descriptor = require "embedded_clusters.Descriptor" end +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + local SwitchLifecycleHandlers = {} function SwitchLifecycleHandlers.device_added(driver, device) @@ -37,38 +41,43 @@ function SwitchLifecycleHandlers.device_added(driver, device) -- was created after the initial subscription report if device.network_type == device_lib.NETWORK_TYPE_CHILD then device:send(clusters.OnOff.attributes.OnOff:read(device)) - elseif device.network_type == device_lib.NETWORK_TYPE_MATTER then - switch_utils.handle_electrical_sensor_info(device) end - -- call device init in case init is not called after added due to device caching - SwitchLifecycleHandlers.device_init(driver, device) + -- The device init event is guaranteed in FW versions 58+, so this is only needed for older hubs + if version.rpc < 10 then + -- call device init in case init is not called after added due to device caching + SwitchLifecycleHandlers.device_init(driver, device) + end end function SwitchLifecycleHandlers.do_configure(driver, device) if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then switch_cfg.set_device_control_options(device) device_cfg.match_profile(driver, device) + elseif device.network_type == device_lib.NETWORK_TYPE_CHILD then + device_cfg.match_child_profile(driver, device) + -- because get_parent_device() may cause race conditions if used in init, an initial child subscribe is handled in doConfigure. + -- all future calls to subscribe will be handled by the parent device in init + device:subscribe() end end function SwitchLifecycleHandlers.driver_switched(driver, device) if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then - switch_utils.handle_electrical_sensor_info(device) -- field settings required for proper setup when switching drivers device_cfg.match_profile(driver, device) end + device:try_update_metadata({provisioning_state = "PROVISIONED"}) end function SwitchLifecycleHandlers.info_changed(driver, device, event, args) - if device.profile.id ~= args.old_st_store.profile.id or device:get_field(fields.MODULAR_PROFILE_UPDATED) then - device:set_field(fields.MODULAR_PROFILE_UPDATED, nil) + if not switch_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then if device.network_type == device_lib.NETWORK_TYPE_MATTER then device:subscribe() button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) ) elseif device.network_type == device_lib.NETWORK_TYPE_CHILD then - switch_utils.update_subscriptions(device:get_parent_device()) -- parent device required to scan through EPs and update subscriptions + device:get_parent_device():subscribe() -- parent device required to send subscription requests end end @@ -77,6 +86,18 @@ function SwitchLifecycleHandlers.info_changed(driver, device, event, args) device_cfg.match_profile(driver, device) end end + + -- instant update of values after offset preference change + for name, info in pairs(device.preferences or {}) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if name == "tempOffset" then + device:send(clusters.TemperatureMeasurement.attributes.MeasuredValue:read(device)) + elseif name == "humidityOffset" then + device:send(clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:read(device)) + end + end + end + end function SwitchLifecycleHandlers.device_init(driver, device) @@ -87,14 +108,12 @@ function SwitchLifecycleHandlers.device_init(driver, device) if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then device:set_find_child(switch_utils.find_child) end - switch_utils.update_subscriptions(device) - - -- device energy reporting must be handled cumulatively, periodically, or by both simulatanously. - -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. - if #embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID, - {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY}) > 0 then - device:set_field(fields.CUMULATIVE_REPORTS_SUPPORTED, true, {persist = false}) + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) == 0 then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) end + switch_utils.handle_electrical_sensor_info(device) + device:extend_device("subscribe", switch_utils.subscribe) + device:subscribe() end end @@ -139,6 +158,11 @@ local matter_driver_template = { [clusters.FanControl.attributes.FanModeSequence.ID] = attribute_handlers.fan_mode_sequence_handler, [clusters.FanControl.attributes.PercentCurrent.ID] = attribute_handlers.percent_current_handler }, + [clusters.FlowMeasurement.ID] = { + [clusters.FlowMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.flow_attr_handler, + [clusters.FlowMeasurement.attributes.MinMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(fields.FLOW_MIN), + [clusters.FlowMeasurement.attributes.MaxMeasuredValue.ID] = attribute_handlers.flow_attr_handler_factory(fields.FLOW_MAX) + }, [clusters.IlluminanceMeasurement.ID] = { [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = attribute_handlers.illuminance_measured_value_handler }, @@ -150,6 +174,11 @@ local matter_driver_template = { [clusters.OccupancySensing.ID] = { [clusters.OccupancySensing.attributes.Occupancy.ID] = attribute_handlers.occupancy_handler, }, + [clusters.OperationalState.ID] = { + [clusters.OperationalState.attributes.AcceptedCommandList.ID] = attribute_handlers.operational_state_accepted_command_list_attr_handler, + [clusters.OperationalState.attributes.OperationalState.ID] = attribute_handlers.operational_state_attr_handler, + [clusters.OperationalState.attributes.OperationalError.ID] = attribute_handlers.operational_error_attr_handler + }, [clusters.OnOff.ID] = { [clusters.OnOff.attributes.OnOff.ID] = attribute_handlers.on_off_attr_handler, }, @@ -217,17 +246,24 @@ local matter_driver_template = { [capabilities.fanSpeedPercent.ID] = { clusters.FanControl.attributes.PercentCurrent }, + [capabilities.flowMeasurement.ID] = { + clusters.FlowMeasurement.attributes.MeasuredValue, + clusters.FlowMeasurement.attributes.MinMeasuredValue, + clusters.FlowMeasurement.attributes.MaxMeasuredValue + }, [capabilities.illuminanceMeasurement.ID] = { clusters.IlluminanceMeasurement.attributes.MeasuredValue }, - [capabilities.motionSensor.ID] = { - clusters.OccupancySensing.attributes.Occupancy - }, [capabilities.level.ID] = { clusters.ValveConfigurationAndControl.attributes.CurrentLevel }, - [capabilities.switch.ID] = { - clusters.OnOff.attributes.OnOff + [capabilities.motionSensor.ID] = { + clusters.OccupancySensing.attributes.Occupancy + }, + [capabilities.operationalState.ID] = { + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.OperationalState.attributes.OperationalState, + clusters.OperationalState.attributes.OperationalError }, [capabilities.powerMeter.ID] = { clusters.ElectricalPowerMeasurement.attributes.ActivePower @@ -235,6 +271,9 @@ local matter_driver_template = { [capabilities.relativeHumidityMeasurement.ID] = { clusters.RelativeHumidityMeasurement.attributes.MeasuredValue }, + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, [capabilities.switchLevel.ID] = { clusters.LevelControl.attributes.CurrentLevel, clusters.LevelControl.attributes.MaxLevel, @@ -278,6 +317,16 @@ local matter_driver_template = { [capabilities.level.ID] = { [capabilities.level.commands.setLevel.NAME] = capability_handlers.handle_set_level }, + [capabilities.operationalState.ID] = { + [capabilities.operationalState.commands.pause.NAME] = capability_handlers.handle_operational_state_pause, + [capabilities.operationalState.commands.resume.NAME] = capability_handlers.handle_operational_state_resume + }, + [capabilities.statelessColorTemperatureStep.ID] = { + [capabilities.statelessColorTemperatureStep.commands.stepColorTemperatureByPercent.NAME] = capability_handlers.handle_step_color_temperature_by_percent, + }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = capability_handlers.handle_step_level, + }, [capabilities.switch.ID] = { [capabilities.switch.commands.off.NAME] = capability_handlers.handle_switch_off, [capabilities.switch.commands.on.NAME] = capability_handlers.handle_switch_on, @@ -290,14 +339,52 @@ local matter_driver_template = { [capabilities.valve.commands.open.NAME] = capability_handlers.handle_valve_open, }, }, - supported_capabilities = fields.supported_capabilities, + supported_capabilities = { + capabilities.audioMute, + capabilities.audioRecording, + capabilities.audioVolume, + capabilities.battery, + capabilities.batteryLevel, + capabilities.button, + capabilities.cameraPrivacyMode, + capabilities.cameraViewportSettings, + capabilities.colorControl, + capabilities.colorTemperature, + capabilities.energyMeter, + capabilities.fanMode, + capabilities.fanSpeedPercent, + capabilities.flowMeasurement, + capabilities.hdr, + capabilities.illuminanceMeasurement, + capabilities.imageControl, + capabilities.level, + capabilities.localMediaStorage, + capabilities.mechanicalPanTiltZoom, + capabilities.motionSensor, + capabilities.nightVision, + capabilities.operationalState, + capabilities.powerMeter, + capabilities.powerConsumptionReport, + capabilities.relativeHumidityMeasurement, + capabilities.sounds, + capabilities.switch, + capabilities.switchLevel, + capabilities.temperatureMeasurement, + capabilities.valve, + capabilities.videoStreamSettings, + capabilities.webrtc, + capabilities.zoneManagement + }, sub_drivers = { switch_utils.lazy_load_if_possible("sub_drivers.aqara_cube"), switch_utils.lazy_load("sub_drivers.camera"), switch_utils.lazy_load_if_possible("sub_drivers.eve_energy"), + switch_utils.lazy_load_if_possible("sub_drivers.hager"), switch_utils.lazy_load_if_possible("sub_drivers.ikea_scroll"), + switch_utils.lazy_load_if_possible("sub_drivers.third_reality_garage_door"), switch_utils.lazy_load_if_possible("sub_drivers.third_reality_mk1") - } + }, + shared_device_thread_enabled = true, } local matter_driver = MatterDriver("matter-switch", matter_driver_template) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua index 4e6b729fa3..ca9dfa76cf 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/aqara_cube/init.lua @@ -184,7 +184,9 @@ end local function do_configure(driver, device) end -- override driver_switched to prevent it running in the main driver -local function driver_switched(driver, device) end +local function driver_switched(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end local function initial_press_event_handler(driver, device, ib, response) if get_field_for_endpoint(device, INITIAL_PRESS_ONLY, ib.endpoint_id) then 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 484e4c7a15..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 @@ -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 @@ -59,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 @@ -70,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) @@ -113,9 +125,6 @@ end function CameraAttributeHandlers.rate_distortion_trade_off_points_handler(driver, device, ib, response) if not ib.data.elements then return end local resolutions = {} - local max_encoded_pixel_rate = device:get_field(camera_fields.MAX_ENCODED_PIXEL_RATE) - local max_fps = device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) - local emit_capability = max_encoded_pixel_rate ~= nil and max_fps ~= nil for _, v in ipairs(ib.data.elements) do local rate_distortion_trade_off_points = v.elements local width = rate_distortion_trade_off_points.resolution.elements.width.value @@ -124,89 +133,96 @@ function CameraAttributeHandlers.rate_distortion_trade_off_points_handler(driver width = width, height = height }) - if emit_capability then - local fps = camera_utils.compute_fps(max_encoded_pixel_rate, width, height, max_fps) - if fps > 0 then - resolutions[#resolutions].fps = fps - end - end - end - if emit_capability then - device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(resolutions)) end device:set_field(camera_fields.SUPPORTED_RESOLUTIONS, resolutions) + local max_encoded_pixel_rate = device:get_field(camera_fields.MAX_ENCODED_PIXEL_RATE) + local max_fps = device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) + if max_encoded_pixel_rate and max_fps and device:get_field(camera_fields.MAX_RESOLUTION) and device:get_field(camera_fields.MIN_RESOLUTION) then + local supported_resolutions = camera_utils.build_supported_resolutions(device, max_encoded_pixel_rate, max_fps) + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(supported_resolutions)) + end end function CameraAttributeHandlers.max_encoded_pixel_rate_handler(driver, device, ib, response) - local resolutions = device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) + device:set_field(camera_fields.MAX_ENCODED_PIXEL_RATE, ib.data.value) local max_fps = device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) - local emit_capability = resolutions ~= nil and max_fps ~= nil - if emit_capability then - for _, v in pairs(resolutions or {}) do - local fps = camera_utils.compute_fps(ib.data.value, v.width, v.height, max_fps) - if fps > 0 then - v.fps = fps - end - end - device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(resolutions)) + if max_fps and device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) and device:get_field(camera_fields.MAX_RESOLUTION) and device:get_field(camera_fields.MIN_RESOLUTION) then + local supported_resolutions = camera_utils.build_supported_resolutions(device, ib.data.value, max_fps) + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(supported_resolutions)) end - device:set_field(camera_fields.MAX_ENCODED_PIXEL_RATE, ib.data.value) end function CameraAttributeHandlers.video_sensor_parameters_handler(driver, device, ib, response) if not ib.data.elements then return end - local resolutions = device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) + local sensor_width = ib.data.elements.sensor_width.value + local sensor_height = ib.data.elements.sensor_height.value + local max_fps = ib.data.elements.max_fps.value + device:set_field(camera_fields.MAX_RESOLUTION, { + width = sensor_width, + height = sensor_height + }) + device:set_field(camera_fields.MAX_FRAMES_PER_SECOND, max_fps) + device:emit_event_for_endpoint(ib, capabilities.cameraViewportSettings.videoSensorParameters({ + width = sensor_width, + height = sensor_height, + maxFPS = max_fps + })) local max_encoded_pixel_rate = device:get_field(camera_fields.MAX_ENCODED_PIXEL_RATE) - local emit_capability = resolutions ~= nil and max_encoded_pixel_rate ~= nil - local sensor_width, sensor_height, max_fps - for _, v in pairs(ib.data.elements) do - if v.field_id == 0 then - sensor_width = v.value - elseif v.field_id == 1 then - sensor_height = v.value - elseif v.field_id == 2 then - max_fps = v.value - end - end - - if max_fps then - if sensor_width and sensor_height then - device:emit_event_for_endpoint(ib, capabilities.cameraViewportSettings.videoSensorParameters({ - width = sensor_width, - height = sensor_height, - maxFPS = max_fps - })) - end - if emit_capability then - for _, v in pairs(resolutions or {}) do - local fps = camera_utils.compute_fps(max_encoded_pixel_rate, v.width, v.height, max_fps) - if fps > 0 then - v.fps = fps - end - end - device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(resolutions)) - end - device:set_field(camera_fields.MAX_FRAMES_PER_SECOND, max_fps) + if max_encoded_pixel_rate and max_fps and device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) and device:get_field(camera_fields.MIN_RESOLUTION) then + local supported_resolutions = camera_utils.build_supported_resolutions(device, max_encoded_pixel_rate, max_fps) + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(supported_resolutions)) end end function CameraAttributeHandlers.min_viewport_handler(driver, device, ib, response) + if not ib.data.elements then return end device:emit_event_for_endpoint(ib, capabilities.cameraViewportSettings.minViewportResolution({ width = ib.data.elements.width.value, height = ib.data.elements.height.value })) + device:set_field(camera_fields.MIN_RESOLUTION, { + width = ib.data.elements.width.value, + height = ib.data.elements.height.value + }) + local max_encoded_pixel_rate = device:get_field(camera_fields.MAX_ENCODED_PIXEL_RATE) + local max_fps = device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) + if max_encoded_pixel_rate and max_fps and device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) and device:get_field(camera_fields.MAX_RESOLUTION) then + local supported_resolutions = camera_utils.build_supported_resolutions(device, max_encoded_pixel_rate, max_fps) + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.supportedResolutions(supported_resolutions)) + end end function CameraAttributeHandlers.allocated_video_streams_handler(driver, device, ib, response) if not ib.data.elements then return end + + local dptz_viewports = device:get_field(camera_fields.DPTZ_VIEWPORTS) or {} local streams = {} + + local previous_streams = device:get_latest_state( + camera_fields.profile_components.main, + capabilities.videoStreamSettings.ID, + capabilities.videoStreamSettings.videoStreams.NAME + ) or {} + + local previous_stream_labels = {} + + for _, stream in pairs(previous_streams) do + previous_stream_labels[stream.streamId] = stream.data.label + end + for i, v in ipairs(ib.data.elements) do local stream = v.elements + local stream_id = stream.video_stream_id.value + + -- Use label from existing capability state, if available + local capability_label = previous_stream_labels[stream_id] + local video_stream = { - streamId = stream.video_stream_id.value, + streamId = stream_id, data = { - label = "Stream " .. i, - type = stream.stream_usage.value == clusters.Global.types.StreamUsageEnum.LIVE_VIEW and "liveStream" or "clipRecording", + label = capability_label or "Stream " .. i, + type = stream.stream_usage.value == + clusters.Global.types.StreamUsageEnum.LIVE_VIEW and "liveStream" or "clipRecording", resolution = { width = stream.min_resolution.elements.width.value, height = stream.min_resolution.elements.height.value, @@ -214,21 +230,31 @@ function CameraAttributeHandlers.allocated_video_streams_handler(driver, device, } } } - local viewport = device:get_field(camera_fields.VIEWPORT) - if viewport then - video_stream.data.viewport = viewport + + if dptz_viewports[stream_id] ~= nil then + video_stream.data.viewport = dptz_viewports[stream_id] + else + video_stream.data.viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { + x = stream.min_resolution.elements.width.value, + y = stream.min_resolution.elements.height.value + } + } end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then + + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, + clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then video_stream.data.watermark = stream.watermark_enabled.value and "enabled" or "disabled" end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, + clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then video_stream.data.onScreenDisplay = stream.osd_enabled.value and "enabled" or "disabled" end table.insert(streams, video_stream) end - if #streams > 0 then - device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.videoStreams(streams)) - end + + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.videoStreams(streams)) end function CameraAttributeHandlers.viewport_handler(driver, device, ib, response) @@ -238,7 +264,47 @@ function CameraAttributeHandlers.viewport_handler(driver, device, ib, response) })) end +function CameraAttributeHandlers.dptz_streams_handler(driver, device, ib, response) + if not ib.data.elements then return end + + local dptz_viewports = {} + for _, v in ipairs(ib.data.elements) do + local dptz_struct = v.elements + local stream_id = dptz_struct.video_stream_id.value + local viewport = dptz_struct.viewport.elements + + dptz_viewports[stream_id] = { + upperLeftVertex = { x = viewport.x1.value, y = viewport.y1.value }, + lowerRightVertex = { x = viewport.x2.value, y = viewport.y2.value } + } + end + + device:set_field(camera_fields.DPTZ_VIEWPORTS, dptz_viewports) + + local current_streams = device:get_latest_state( + camera_fields.profile_components.main, + capabilities.videoStreamSettings.ID, + capabilities.videoStreamSettings.videoStreams.NAME + ) or {} + local updated_streams = {} + for _, stream in pairs(current_streams) do + local updated_stream = { + streamId = stream.streamId, + data = stream.data + } + if dptz_viewports[stream.streamId] ~= nil then + updated_stream.data.viewport = dptz_viewports[stream.streamId] + end + table.insert(updated_streams, updated_stream) + end + + if #updated_streams > 0 then + device:emit_event_for_endpoint(ib, capabilities.videoStreamSettings.videoStreams(updated_streams)) + end +end + function CameraAttributeHandlers.ptz_position_handler(driver, device, ib, response) + if not ib.data.elements then return end local ptz_map = camera_utils.get_ptz_map(device) local emit_event = function(idx, value) if value ~= ptz_map[idx].current then @@ -247,13 +313,13 @@ function CameraAttributeHandlers.ptz_position_handler(driver, device, ib, respon )) end end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + if ib.data.elements.pan and camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN) then emit_event(camera_fields.PAN_IDX, ib.data.elements.pan.value) end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + if ib.data.elements.tilt and camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT) then emit_event(camera_fields.TILT_IDX, ib.data.elements.tilt.value) end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + if ib.data.elements.zoom and camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then emit_event(camera_fields.ZOOM_IDX, ib.data.elements.zoom.value) end end @@ -264,13 +330,13 @@ function CameraAttributeHandlers.ptz_presets_handler(driver, device, ib, respons for _, v in ipairs(ib.data.elements) do local preset = v.elements local pan, tilt, zoom = 0, 0, 1 - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN) then pan = preset.settings.elements.pan.value end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT) then tilt = preset.settings.elements.tilt.value end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then zoom = preset.settings.elements.zoom.value end table.insert(presets, { id = preset.preset_id.value, label = preset.name.value, pan = pan, tilt = tilt, zoom = zoom }) @@ -364,7 +430,8 @@ function CameraAttributeHandlers.triggers_handler(driver, device, ib, response) augmentationDuration = trigger.augmentation_duration.value, maxDuration = trigger.max_duration.value, blindDuration = trigger.blind_duration.value, - sensitivity = camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) and trigger.sensitivity.value + sensitivity = camera_utils.feature_supported(device, clusters.ZoneManagement.ID, + clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) and trigger.sensitivity.value or nil }) end device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggers(triggers)) @@ -395,7 +462,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 @@ -404,6 +471,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 {} @@ -413,7 +482,13 @@ function CameraAttributeHandlers.camera_av_stream_management_attribute_list_hand attribute_ids = attribute_ids, } device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist=true}) - camera_cfg.match_profile(device, status_light_enabled_present, status_light_brightness_present) + 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 + +function CameraAttributeHandlers.camera_feature_map_handler(driver, device, ib, response) + camera_cfg.reconcile_profile_and_capabilities(device) end -return CameraAttributeHandlers \ No newline at end of file +return CameraAttributeHandlers 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 fb85eb863f..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 @@ -139,9 +139,11 @@ end CameraCapabilityHandlers.ptz_relative_move_factory = function(index) return function (driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local pan_delta = index == camera_fields.PAN_IDX and cmd.args.delta or 0 - local tilt_delta = index == camera_fields.TILT_IDX and cmd.args.delta or 0 - local zoom_delta = index == camera_fields.ZOOM_IDX and cmd.args.delta or 0 + -- Only the axis being moved should be included; the other two must be omitted (nil) rather + -- than sent as 0, since each delta field's conformance is tied to its own feature bit. + local pan_delta = index == camera_fields.PAN_IDX and cmd.args.delta or nil + local tilt_delta = index == camera_fields.TILT_IDX and cmd.args.delta or nil + local zoom_delta = index == camera_fields.ZOOM_IDX and cmd.args.delta or nil device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove( device, endpoint_id, pan_delta, tilt_delta, zoom_delta )) @@ -151,23 +153,29 @@ end CameraCapabilityHandlers.ptz_set_position_factory = function(command) return function (driver, device, cmd) local ptz_map = camera_utils.get_ptz_map(device) + -- MPTZSetPosition coordinates are optional; + -- a single-axis command should only set (and clamp) the axis it targets. + local pan, tilt, zoom if command == capabilities.mechanicalPanTiltZoom.commands.setPanTiltZoom then - ptz_map[camera_fields.PAN_IDX].current = cmd.args.pan - ptz_map[camera_fields.TILT_IDX].current = cmd.args.tilt - ptz_map[camera_fields.ZOOM_IDX].current = cmd.args.zoom + 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 - ptz_map[camera_fields.PAN_IDX].current = cmd.args.pan + 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 - ptz_map[camera_fields.TILT_IDX].current = cmd.args.tilt + tilt = utils.clamp_value(cmd.args.tilt, ptz_map[camera_fields.TILT_IDX].range.minimum, ptz_map[camera_fields.TILT_IDX].range.maximum) else - ptz_map[camera_fields.ZOOM_IDX].current = cmd.args.zoom - end - for _, v in pairs(ptz_map) do - v.current = utils.clamp_value(v.current, v.range.minimum, v.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) end local endpoint_id = device:component_to_endpoint(cmd.component) - device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(device, endpoint_id, - ptz_map[camera_fields.PAN_IDX].current, ptz_map[camera_fields.TILT_IDX].current, ptz_map[camera_fields.ZOOM_IDX].current + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition( + device, endpoint_id, pan, tilt, zoom )) end end @@ -259,13 +267,24 @@ end function CameraCapabilityHandlers.handle_remove_zone(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) + local triggers = device:get_latest_state( + camera_fields.profile_components.main, capabilities.zoneManagement.ID, capabilities.zoneManagement.triggers.NAME + ) or {} + for _, v in pairs(triggers) do + if v.zoneId == cmd.args.zoneId then + device:send(clusters.ZoneManagement.server.commands.RemoveTrigger(device, endpoint_id, cmd.args.zoneId)) + break + end + end device:send(clusters.ZoneManagement.server.commands.RemoveZone(device, endpoint_id, cmd.args.zoneId)) end function CameraCapabilityHandlers.handle_create_or_update_trigger(driver, device, cmd) + local per_zone_sensitivity_supported = camera_utils.feature_supported( + device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY + ) if not cmd.args.augmentationDuration or not cmd.args.maxDuration or not cmd.args.blindDuration or - (camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) and - not cmd.args.sensitivity) then + (per_zone_sensitivity_supported and not cmd.args.sensitivity) then local triggers = device:get_latest_state( camera_fields.profile_components.main, capabilities.zoneManagement.ID, capabilities.zoneManagement.triggers.NAME ) or {} @@ -275,8 +294,7 @@ function CameraCapabilityHandlers.handle_create_or_update_trigger(driver, device if not cmd.args.augmentationDuration then cmd.args.augmentationDuration = v.augmentationDuration end if not cmd.args.maxDuration then cmd.args.maxDuration = v.maxDuration end if not cmd.args.blindDuration then cmd.args.blindDuration = v.blindDuration end - if camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) and - not cmd.args.sensitivity then + if per_zone_sensitivity_supported and not cmd.args.sensitivity then cmd.args.sensitivity = v.sensitivity end found_trigger = true @@ -297,7 +315,7 @@ function CameraCapabilityHandlers.handle_create_or_update_trigger(driver, device augmentation_duration = cmd.args.augmentationDuration, max_duration = cmd.args.maxDuration, blind_duration = cmd.args.blindDuration, - sensitivity = cmd.args.sensitivity + sensitivity = per_zone_sensitivity_supported and cmd.args.sensitivity or nil -- omit even if provided by client if per-zone sensitivity is not supported } ) )) @@ -311,7 +329,7 @@ end function CameraCapabilityHandlers.handle_set_sensitivity(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) if not camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) then - device:send(clusters.ZoneManagement.attributes.Sensitivity:write(device, endpoint_id, cmd.args.id)) + device:send(clusters.ZoneManagement.attributes.Sensitivity:write(device, endpoint_id, cmd.args.sensitivity)) else device.log.warn(string.format("Can't set global zone sensitivity setting, per zone sensitivity enabled.")) end @@ -327,17 +345,153 @@ function CameraCapabilityHandlers.handle_set_selected_sound(driver, device, cmd) device:send(clusters.Chime.attributes.SelectedChime:write(device, endpoint_id, cmd.args.id)) end +local function update_viewport_or_label(device, current_streams, streamId, label, viewport) + current_streams = current_streams or {} + for _, stream in ipairs(current_streams) do + if stream.streamId == streamId then + if label ~= nil then stream.data.label = label end + if viewport ~= nil then stream.data.viewport = viewport end + break + end + end + local endpoint_id = device:component_to_endpoint() + device:emit_event_for_endpoint(endpoint_id, capabilities.videoStreamSettings.videoStreams(current_streams)) +end + function CameraCapabilityHandlers.handle_set_stream(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) + local watermark_enabled, on_screen_display_enabled - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then - watermark_enabled = cmd.args.watermark == "enabled" + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, + clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then + if cmd.args.watermark ~= nil then + watermark_enabled = cmd.args.watermark == "enabled" + end + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, + clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then + if cmd.args.onScreenDisplay ~= nil then + on_screen_display_enabled = cmd.args.onScreenDisplay == "enabled" + end + end + + local current_streams = device:get_latest_state( + camera_fields.profile_components.main, + capabilities.videoStreamSettings.ID, + capabilities.videoStreamSettings.videoStreams.NAME + ) or {} + local current_stream + for _, stream in pairs(current_streams) do + if stream.streamId == cmd.args.streamId then + current_stream = stream.data + break + end + end + + local needs_reallocation = false + if current_stream ~= nil and current_stream.type ~= cmd.args.type then + needs_reallocation = true + elseif current_stream ~= nil and cmd.args.resolution ~= nil then + if current_stream.resolution.width ~= cmd.args.resolution.width or + current_stream.resolution.height ~= cmd.args.resolution.height or + current_stream.resolution.fps ~= cmd.args.resolution.fps then + needs_reallocation = true + end + elseif current_stream == nil and (cmd.args.type ~= nil or cmd.args.resolution ~= nil) then + needs_reallocation = true + end + + local viewport_changed = false + if cmd.args.viewport ~= nil and + camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, + clusters.CameraAvSettingsUserLevelManagement.types.Feature.DIGITALPTZ) then + if current_stream ~= nil and current_stream.viewport ~= nil then + if current_stream.viewport.upperLeftVertex.x ~= cmd.args.viewport.upperLeftVertex.x or + current_stream.viewport.upperLeftVertex.y ~= cmd.args.viewport.upperLeftVertex.y or + current_stream.viewport.lowerRightVertex.x ~= cmd.args.viewport.lowerRightVertex.x or + current_stream.viewport.lowerRightVertex.y ~= cmd.args.viewport.lowerRightVertex.y then + viewport_changed = true + end + elseif current_stream == nil or current_stream.viewport == nil then + viewport_changed = true + end + + if viewport_changed then + device:send(clusters.CameraAvSettingsUserLevelManagement.server.commands.DPTZSetViewport(device, endpoint_id, + cmd.args.streamId, + clusters.Global.types.ViewportStruct({ + x1 = cmd.args.viewport.upperLeftVertex.x, + x2 = cmd.args.viewport.lowerRightVertex.x, + y1 = cmd.args.viewport.upperLeftVertex.y, + y2 = cmd.args.viewport.lowerRightVertex.y + }) + )) + end + end + + local label_changed = cmd.args.label ~= nil and cmd.args.label ~= current_stream.label + + if viewport_changed or label_changed then + update_viewport_or_label(device, current_streams, cmd.args.streamId, cmd.args.label, cmd.args.viewport) + end + + if not needs_reallocation then + local watermark_changed = watermark_enabled ~= nil and current_stream.watermark ~= nil and + ((watermark_enabled and current_stream.watermark == "disabled") or + (not watermark_enabled and current_stream.watermark == "enabled")) + local on_screen_display_changed = on_screen_display_enabled ~= nil and current_stream.onScreenDisplay ~= nil and + ((on_screen_display_enabled and current_stream.onScreenDisplay == "disabled") or + (not on_screen_display_enabled and current_stream.onScreenDisplay == "enabled")) + if watermark_changed or on_screen_display_changed then + device:send(clusters.CameraAvStreamManagement.server.commands.VideoStreamModify( + device, endpoint_id, cmd.args.streamId, watermark_enabled, on_screen_display_enabled + )) + end + return end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then - on_screen_display_enabled = cmd.args.onScreenDisplay == "enabled" + + device:send(clusters.CameraAvStreamManagement.server.commands.VideoStreamDeallocate( + device, endpoint_id, cmd.args.streamId + )) + + local stream_usage = cmd.args.type == "liveStream" and + clusters.Global.types.StreamUsageEnum.LIVE_VIEW or clusters.Global.types.StreamUsageEnum.RECORDING + + local min_resolution, max_resolution + if cmd.args.resolution ~= nil then + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = cmd.args.resolution.width, + height = cmd.args.resolution.height + }) + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = cmd.args.resolution.width, + height = cmd.args.resolution.height + }) end - device:send(clusters.CameraAvStreamManagement.server.commands.VideoStreamModify(device, endpoint_id, - cmd.args.streamId, watermark_enabled, on_screen_display_enabled + + if watermark_enabled == nil then + watermark_enabled = camera_fields.video_stream_defaults.watermark_enabled + end + + if on_screen_display_enabled == nil then + on_screen_display_enabled = camera_fields.video_stream_defaults.on_screen_display_enabled + end + + -- Use the same resolution (if available) for MinResolution and MaxResolution to force the server to allocate the + -- stream with the desired resolution. + device:send(clusters.CameraAvStreamManagement.server.commands.VideoStreamAllocate(device, endpoint_id, + stream_usage, + camera_fields.video_stream_defaults.codec, + camera_fields.video_stream_defaults.min_frame_rate, + math.min(device:get_field(camera_fields.MAX_FRAMES_PER_SECOND) or camera_fields.video_stream_defaults.max_frame_rate, + camera_fields.video_stream_defaults.max_frame_rate), + min_resolution or device:get_field(camera_fields.MIN_RESOLUTION) or camera_fields.video_stream_defaults.min_resolution, + max_resolution or device:get_field(camera_fields.MAX_RESOLUTION) or camera_fields.video_stream_defaults.max_resolution, + camera_fields.video_stream_defaults.min_bitrate, + camera_fields.video_stream_defaults.max_bitrate, + camera_fields.video_stream_defaults.key_frame_interval, + watermark_enabled, + on_screen_display_enabled )) end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua index 02b63bb37f..8549b02d18 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/event_handlers.lua @@ -3,14 +3,23 @@ local camera_fields = require "sub_drivers.camera.camera_utils.fields" local capabilities = require "st.capabilities" -local switch_utils = require "switch_utils.utils" local CameraEventHandlers = {} +local function has_triggered_zone(triggered_zones, zone_id) + for _, zone in ipairs(triggered_zones or {}) do + if zone.zoneId == zone_id then + return true + end + end + return false +end + function CameraEventHandlers.zone_triggered_handler(driver, device, ib, response) local triggered_zones = device:get_field(camera_fields.TRIGGERED_ZONES) or {} - if not switch_utils.tbl_contains(triggered_zones, ib.data.elements.zone.value) then - table.insert(triggered_zones, {zoneId = ib.data.elements.zone.value}) + local zone_id = ib.data.elements.zone.value + if not has_triggered_zone(triggered_zones, zone_id) then + table.insert(triggered_zones, { zoneId = zone_id }) device:set_field(camera_fields.TRIGGERED_ZONES, triggered_zones) device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggeredZones(triggered_zones)) end @@ -18,13 +27,22 @@ end function CameraEventHandlers.zone_stopped_handler(driver, device, ib, response) local triggered_zones = device:get_field(camera_fields.TRIGGERED_ZONES) or {} - for i, v in pairs(triggered_zones) do - if v.zoneId == ib.data.elements.zone.value then - table.remove(triggered_zones, i) - device:set_field(camera_fields.TRIGGERED_ZONES, triggered_zones) - device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggeredZones(triggered_zones)) + local zone_id = ib.data.elements.zone.value + local updated_triggered_zones = {} + local zone_removed = false + + for _, zone in ipairs(triggered_zones) do + if zone.zoneId ~= zone_id then + table.insert(updated_triggered_zones, zone) + else + zone_removed = true end end + + if zone_removed then + device:set_field(camera_fields.TRIGGERED_ZONES, updated_triggered_zones) + device:emit_event_for_endpoint(ib, capabilities.zoneManagement.triggeredZones(updated_triggered_zones)) + end end return CameraEventHandlers 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 6626729c78..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 @@ -6,12 +6,204 @@ local camera_fields = require "sub_drivers.camera.camera_utils.fields" local camera_utils = require "sub_drivers.camera.camera_utils.utils" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" +local st_utils = require "st.utils" local device_cfg = require "switch_utils.device_configuration" local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" local CameraDeviceConfiguration = {} +local managed_capability_map = { + { key = "webrtc", capability = capabilities.webrtc }, + { key = "ptz", capability = capabilities.mechanicalPanTiltZoom }, + { key = "zone_management", capability = capabilities.zoneManagement }, + { key = "local_media_storage", capability = capabilities.localMediaStorage }, + { key = "audio_recording", capability = capabilities.audioRecording }, + { key = "video_stream_settings", capability = capabilities.videoStreamSettings }, + { key = "camera_privacy_mode", capability = capabilities.cameraPrivacyMode }, +} + +local function get_status_light_presence(device) + return device:get_field(camera_fields.STATUS_LIGHT_ENABLED_PRESENT), + device:get_field(camera_fields.STATUS_LIGHT_BRIGHTNESS_PRESENT) +end + +local function set_status_light_presence(device, status_light_enabled_present, status_light_brightness_present) + device:set_field(camera_fields.STATUS_LIGHT_ENABLED_PRESENT, status_light_enabled_present == true, { persist = true }) + 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 + +--- 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, + order = "audio/video", + audio = "sendrecv", + video = "recvonly", + turnSource = "player", + supportTrickleICE = true + } +end + +local function build_ptz_supported_attributes(device) + local supported_attributes = {} + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN) then + table.insert(supported_attributes, "pan") + table.insert(supported_attributes, "panRange") + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT) then + table.insert(supported_attributes, "tilt") + table.insert(supported_attributes, "tiltRange") + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then + table.insert(supported_attributes, "zoom") + table.insert(supported_attributes, "zoomRange") + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS) then + table.insert(supported_attributes, "presets") + table.insert(supported_attributes, "maxPresets") + end + return supported_attributes +end + +local function build_zone_management_supported_features(device) + local supported_features = { "triggerAugmentation" } + if camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) then + table.insert(supported_features, "perZoneSensitivity") + end + return supported_features +end + +local function build_local_media_storage_supported_attributes(device) + local supported_attributes = {} + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then + table.insert(supported_attributes, "localVideoRecording") + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then + table.insert(supported_attributes, "localSnapshotRecording") + end + return supported_attributes +end + +local function build_video_stream_settings_supported_features(device) + local supported_features = {} + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then + table.insert(supported_features, "liveStreaming") + table.insert(supported_features, "clipRecording") + table.insert(supported_features, "perStreamViewports") + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then + table.insert(supported_features, "watermark") + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then + table.insert(supported_features, "onScreenDisplay") + end + return supported_features +end + +local function camera_privacy_feature_supported(device) + return camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.PRIVACY) +end + +-- 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) + local capabilities_to_reinit = {} + + local function should_init(capability, attribute, expected) + if device:supports_capability(capability) then + local current = st_utils.deep_copy(device:get_latest_state( + camera_fields.profile_components.main, + capability.ID, + attribute.NAME, + {} + )) + return not switch_utils.deep_equals(current, expected) + end + return false + end + + if should_init(capabilities.webrtc, capabilities.webrtc.supportedFeatures, build_webrtc_supported_features()) then + capabilities_to_reinit.webrtc = true + end + + if should_init(capabilities.mechanicalPanTiltZoom, capabilities.mechanicalPanTiltZoom.supportedAttributes, build_ptz_supported_attributes(device)) then + capabilities_to_reinit.ptz = true + end + + if should_init(capabilities.zoneManagement, capabilities.zoneManagement.supportedFeatures, build_zone_management_supported_features(device)) then + capabilities_to_reinit.zone_management = true + end + + if should_init(capabilities.localMediaStorage, capabilities.localMediaStorage.supportedAttributes, build_local_media_storage_supported_attributes(device)) then + capabilities_to_reinit.local_media_storage = true + end + + if device:supports_capability(capabilities.audioRecording) then + local audio_enabled_state = device:get_latest_state( + camera_fields.profile_components.main, + capabilities.audioRecording.ID, + capabilities.audioRecording.audioRecording.NAME + ) + if audio_enabled_state == nil then + capabilities_to_reinit.audio_recording = true + end + end + + if should_init(capabilities.videoStreamSettings, capabilities.videoStreamSettings.supportedFeatures, build_video_stream_settings_supported_features(device)) then + capabilities_to_reinit.video_stream_settings = true + end + + 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 + + return capabilities_to_reinit +end + function CameraDeviceConfiguration.create_child_devices(driver, device) local num_floodlight_eps = 0 local parent_child_device = false @@ -41,7 +233,10 @@ function CameraDeviceConfiguration.create_child_devices(driver, device) end end -function CameraDeviceConfiguration.match_profile(device, status_light_enabled_present, status_light_brightness_present) +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 = {} local status_led_component_capabilities = {} @@ -66,24 +261,31 @@ function CameraDeviceConfiguration.match_profile(device, status_light_enabled_pr table.insert(main_component_capabilities, capabilities.videoCapture2.ID) end table.insert(main_component_capabilities, capabilities.cameraViewportSettings.ID) + table.insert(main_component_capabilities, capabilities.videoStreamSettings.ID) end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.LOCAL_STORAGE) then table.insert(main_component_capabilities, capabilities.localMediaStorage.ID) end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.AUDIO) then - table.insert(main_component_capabilities, capabilities.audioRecording.ID) + if switch_utils.find_cluster_on_ep(camera_ep, clusters.PushAvStreamTransport.ID, "SERVER") then + 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) 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 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) @@ -103,13 +305,12 @@ function CameraDeviceConfiguration.match_profile(device, status_light_enabled_pr clus_has_feature(clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then table.insert(main_component_capabilities, capabilities.mechanicalPanTiltZoom.ID) end - table.insert(main_component_capabilities, capabilities.videoStreamSettings.ID) - elseif ep_cluster.cluster_id == clusters.ZoneManagement.ID and has_server_cluster_type(ep_cluster) then - table.insert(main_component_capabilities, capabilities.zoneManagement.ID) + elseif ep_cluster.cluster_id == clusters.ZoneManagement.ID and has_server_cluster_type(ep_cluster) and + clusters.ZoneManagement.are_features_supported(clusters.ZoneManagement.types.Feature.USER_DEFINED, ep_cluster.feature_map) then + table.insert(main_component_capabilities, capabilities.zoneManagement.ID) elseif ep_cluster.cluster_id == clusters.OccupancySensing.ID and has_server_cluster_type(ep_cluster) then table.insert(main_component_capabilities, capabilities.motionSensor.ID) - elseif ep_cluster.cluster_id == clusters.WebRTCTransportProvider.ID and has_server_cluster_type(ep_cluster) and - #device:get_endpoints(clusters.WebRTCTransportRequestor.ID, {cluster_type = "CLIENT"}) > 0 then + elseif ep_cluster.cluster_id == clusters.WebRTCTransportProvider.ID and has_server_cluster_type(ep_cluster) then table.insert(main_component_capabilities, capabilities.webrtc.ID) end end @@ -121,8 +322,6 @@ function CameraDeviceConfiguration.match_profile(device, status_light_enabled_pr local doorbell_endpoints = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.DOORBELL) if #doorbell_endpoints > 0 then table.insert(doorbell_component_capabilities, capabilities.button.ID) - CameraDeviceConfiguration.update_doorbell_component_map(device, doorbell_endpoints[1]) - button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) end if status_light_enabled_present then table.insert(status_led_component_capabilities, capabilities.switch.ID) @@ -146,74 +345,42 @@ function CameraDeviceConfiguration.match_profile(device, status_light_enabled_pr end if camera_utils.optional_capabilities_list_changed(optional_supported_component_capabilities, device.profile.components) then + profile_update_requested = true device:try_update_metadata({profile = "camera", optional_component_capabilities = optional_supported_component_capabilities}) + if #doorbell_endpoints > 0 then + CameraDeviceConfiguration.update_doorbell_component_map(device, doorbell_endpoints[1]) + button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) + end end + + return profile_update_requested end local function init_webrtc(device) if device:supports_capability(capabilities.webrtc) then - -- TODO: Check for individual audio/video and talkback features local transport_provider_ep_ids = device:get_endpoints(clusters.WebRTCTransportProvider.ID) - device:emit_event_for_endpoint(transport_provider_ep_ids[1], capabilities.webrtc.supportedFeatures({ - value = { - bundle = true, - order = "audio/video", - audio = "sendrecv", - video = "recvonly", - turnSource = "player", - supportTrickleICE = true - } - })) + device:emit_event_for_endpoint(transport_provider_ep_ids[1], capabilities.webrtc.supportedFeatures(build_webrtc_supported_features())) end end local function init_ptz(device) if device:supports_capability(capabilities.mechanicalPanTiltZoom) then - local supported_attributes = {} - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then - table.insert(supported_attributes, "pan") - table.insert(supported_attributes, "panRange") - end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then - table.insert(supported_attributes, "tilt") - table.insert(supported_attributes, "tiltRange") - end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then - table.insert(supported_attributes, "zoom") - table.insert(supported_attributes, "zoomRange") - end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPRESETS) then - table.insert(supported_attributes, "presets") - table.insert(supported_attributes, "maxPresets") - end local av_settings_ep_ids = device:get_endpoints(clusters.CameraAvSettingsUserLevelManagement.ID) - device:emit_event_for_endpoint(av_settings_ep_ids[1], capabilities.mechanicalPanTiltZoom.supportedAttributes(supported_attributes)) + device:emit_event_for_endpoint(av_settings_ep_ids[1], capabilities.mechanicalPanTiltZoom.supportedAttributes(build_ptz_supported_attributes(device))) end end local function init_zone_management(device) if device:supports_capability(capabilities.zoneManagement) then - local supported_features = {} - table.insert(supported_features, "triggerAugmentation") - if camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) then - table.insert(supported_features, "perZoneSensitivity") - end local zone_management_ep_ids = device:get_endpoints(clusters.ZoneManagement.ID) - device:emit_event_for_endpoint(zone_management_ep_ids[1], capabilities.zoneManagement.supportedFeatures(supported_features)) + device:emit_event_for_endpoint(zone_management_ep_ids[1], capabilities.zoneManagement.supportedFeatures(build_zone_management_supported_features(device))) end end local function init_local_media_storage(device) if device:supports_capability(capabilities.localMediaStorage) then - local supported_attributes = {} - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then - table.insert(supported_attributes, "localVideoRecording") - end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then - table.insert(supported_attributes, "localSnapshotRecording") - end local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.localMediaStorage.supportedAttributes(supported_attributes)) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.localMediaStorage.supportedAttributes(build_local_media_storage_supported_attributes(device))) end end @@ -232,33 +399,16 @@ end local function init_video_stream_settings(device) if device:supports_capability(capabilities.videoStreamSettings) then - local supported_features = {} - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then - table.insert(supported_features, "liveStreaming") - table.insert(supported_features, "clipRecording") - table.insert(supported_features, "perStreamViewports") - end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then - table.insert(supported_features, "watermark") - end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then - table.insert(supported_features, "onScreenDisplay") - end local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.videoStreamSettings.supportedFeatures(supported_features)) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.videoStreamSettings.supportedFeatures(build_video_stream_settings_supported_features(device))) end end local function init_camera_privacy_mode(device) if device:supports_capability(capabilities.cameraPrivacyMode) then - local supported_attributes, supported_commands = {}, {} - table.insert(supported_attributes, "softRecordingPrivacyMode") - table.insert(supported_attributes, "softLivestreamPrivacyMode") - table.insert(supported_commands, "setSoftRecordingPrivacyMode") - table.insert(supported_commands, "setSoftLivestreamPrivacyMode") 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(supported_attributes)) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedCommands(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 @@ -272,6 +422,85 @@ function CameraDeviceConfiguration.initialize_camera_capabilities(device) init_camera_privacy_mode(device) end +local function initialize_selected_camera_capabilities(device, capabilities_to_reinit) + local reinit_targets = capabilities_to_reinit or {} + + if reinit_targets.webrtc then + init_webrtc(device) + end + if reinit_targets.ptz then + init_ptz(device) + end + if reinit_targets.zone_management then + init_zone_management(device) + end + if reinit_targets.local_media_storage then + init_local_media_storage(device) + end + if reinit_targets.audio_recording then + init_audio_recording(device) + end + if reinit_targets.video_stream_settings then + init_video_stream_settings(device) + end + if reinit_targets.camera_privacy_mode then + init_camera_privacy_mode(device) + end +end + +local function profile_capability_set(profile) + local capability_set = {} + for _, component in pairs((profile or {}).components or {}) do + for _, capability in pairs(component.capabilities or {}) do + if capability.id ~= nil then + capability_set[capability.id] = true + end + end + end + return capability_set +end + +local function changed_capabilities_from_profiles(old_profile, new_profile) + local flags = {} + local old_set = profile_capability_set(old_profile) + local new_set = profile_capability_set(new_profile) + + for _, managed in ipairs(managed_capability_map) do + local id = managed.capability.ID + if old_set[id] ~= new_set[id] and new_set[id] == true then + flags[managed.key] = true + end + end + + return flags +end + +function CameraDeviceConfiguration.reconcile_profile_and_capabilities(device) + local profile_update_requested = CameraDeviceConfiguration.match_profile(device) + if not profile_update_requested then + local capabilities_to_reinit = capabilities_needing_reinit(device) + initialize_selected_camera_capabilities(device, capabilities_to_reinit) + end + return profile_update_requested +end + +function CameraDeviceConfiguration.update_status_light_attribute_presence(device, status_light_enabled_present, status_light_brightness_present) + 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) + device:subscribe() + if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.DOORBELL) > 0 then + button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) + end +end + function CameraDeviceConfiguration.update_doorbell_component_map(device, ep) local component_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} component_map.doorbell = ep 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 677e2d5dd6..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 @@ -10,8 +10,17 @@ CameraFields.MAX_FRAMES_PER_SECOND = "__max_frames_per_second" CameraFields.MAX_VOLUME_LEVEL = "__max_volume_level" CameraFields.MIN_VOLUME_LEVEL = "__min_volume_level" CameraFields.SUPPORTED_RESOLUTIONS = "__supported_resolutions" +CameraFields.MAX_RESOLUTION = "__max_resolution" +CameraFields.MIN_RESOLUTION = "__min_resolution" CameraFields.TRIGGERED_ZONES = "__triggered_zones" -CameraFields.VIEWPORT = "__viewport" +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 } +CameraFields.ZoneManagementFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.ZoneManagement.ID } CameraFields.PAN_IDX = "PAN" CameraFields.TILT_IDX = "TILT" @@ -45,4 +54,18 @@ CameraFields.ABS_ZOOM_MIN = 1 CameraFields.ABS_VOL_MAX = 254.0 CameraFields.ABS_VOL_MIN = 0.0 +-- Define defaults for allocating new streams. Note that these are the same values use by the hub. +CameraFields.video_stream_defaults = { + codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 320, height = 240}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + min_bitrate = 10000, + max_bitrate = 2000000, + key_frame_interval = 4000, + watermark_enabled = false, + on_screen_display_enabled = false +} + return CameraFields 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 dcdd936950..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 @@ -102,46 +110,81 @@ function CameraUtils.compute_fps(max_encoded_pixel_rate, width, height, max_fps) return math.tointeger(math.floor(fps / fps_step) * fps_step) end -function CameraUtils.profile_changed(synced_components, prev_components) - if #synced_components ~= #prev_components then - return true - end - for _, component in pairs(synced_components or {}) do - if (prev_components[component.id] == nil) or - (#component.capabilities ~= #prev_components[component.id].capabilities) then - return true - end - for _, capability in pairs(component.capabilities or {}) do - if prev_components[component.id][capability.id] == nil then - return true - end +function CameraUtils.build_supported_resolutions(device, max_encoded_pixel_rate, max_fps) + local resolutions = {} + local added_resolutions = {} + + local function add_resolution(width, height) + local key = width .. "x" .. height + if not added_resolutions[key] then + local resolution = { width = width, height = height } + resolution.fps = CameraUtils.compute_fps(max_encoded_pixel_rate, width, height, max_fps) + table.insert(resolutions, resolution) + added_resolutions[key] = true end end - return false + + local min_resolution = device:get_field(camera_fields.MIN_RESOLUTION) + if min_resolution then + add_resolution(min_resolution.width, min_resolution.height) + end + + local trade_off_resolutions = device:get_field(camera_fields.SUPPORTED_RESOLUTIONS) + for _, v in pairs(trade_off_resolutions or {}) do + add_resolution(v.width, v.height) + end + + local max_resolution = device:get_field(camera_fields.MAX_RESOLUTION) + if max_resolution then + add_resolution(max_resolution.width, max_resolution.height) + end + + return resolutions end -function CameraUtils.optional_capabilities_list_changed(optional_capabilities, prev_component_list) - local prev_optional_capabilities = {} - for idx, comp in pairs(prev_component_list or {}) do - local cap_list = {} - for _, capability in pairs(comp.capabilities or {}) do - table.insert(cap_list, capability.id) +function CameraUtils.optional_capabilities_list_changed(new_component_capability_list, previous_component_capability_list) + local previous_capability_map = {} + local component_sizes = {} + + local previous_component_count = 0 + for component_name, component in pairs(previous_component_capability_list or {}) do + previous_capability_map[component_name] = {} + component_sizes[component_name] = 0 + for _, capability in pairs(component.capabilities or {}) do + if capability.id ~= "firmwareUpdate" and capability.id ~= "refresh" then + previous_capability_map[component_name][capability.id] = true + component_sizes[component_name] = component_sizes[component_name] + 1 + end end - table.insert(prev_optional_capabilities, {idx, cap_list}) + previous_component_count = previous_component_count + 1 end - if #optional_capabilities ~= #prev_optional_capabilities then - return true - end - for _, capability in pairs(optional_capabilities or {}) do - if not switch_utils.tbl_contains(prev_optional_capabilities, capability) then + + local number_of_components_counted = 0 + for _, new_component_capabilities in pairs(new_component_capability_list or {}) do + local component_name = new_component_capabilities[1] + local capability_list = new_component_capabilities[2] + + number_of_components_counted = number_of_components_counted + 1 + + if previous_capability_map[component_name] == nil then return true end - end - for _, capability in pairs(prev_optional_capabilities or {}) do - if not switch_utils.tbl_contains(optional_capabilities, capability) then + + for _, capability in ipairs(capability_list) do + if previous_capability_map[component_name][capability] == nil then + return true + end + end + + if #capability_list ~= component_sizes[component_name] then return true end end + + if number_of_components_counted ~= previous_component_count then + return true + end + return false end @@ -177,29 +220,35 @@ 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 }, [capabilities.switch.ID] = { - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.OnOff.attributes.OnOff }, [capabilities.videoStreamSettings.ID] = { clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, clusters.CameraAvStreamManagement.attributes.VideoSensorParams, - clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams }, [capabilities.zoneManagement.ID] = { clusters.ZoneManagement.attributes.MaxZones, @@ -223,8 +272,26 @@ function CameraUtils.subscribe(device) }, [capabilities.motionSensor.ID] = { clusters.OccupancySensing.attributes.Occupancy - } + }, + [capabilities.switchLevel.ID] = { + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + }, + [capabilities.colorControl.ID] = { + clusters.ColorControl.attributes.ColorMode, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + }, + [capabilities.colorTemperature.ID] = { + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + }, } + local camera_subscribed_events = { [capabilities.zoneManagement.ID] = { clusters.ZoneManagement.events.ZoneTriggered, @@ -238,56 +305,46 @@ function CameraUtils.subscribe(device) } } - for capability, attr_list in pairs(camera_subscribed_attributes) do - if device:supports_capability_by_id(capability) then - for _, attr in pairs(attr_list) do - device:add_subscribed_attribute(attr) - end - end - end - for capability, event_list in pairs(camera_subscribed_events) do - if device:supports_capability_by_id(capability) then - for _, event in pairs(event_list) do - device:add_subscribed_event(event) - end - end - end + local im = require "st.matter.interaction_model" + + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + local devices_seen, capabilities_seen, attributes_seen, events_seen = {}, {}, {}, {} + local additional_attributes = {} - -- match_profile is called from the CameraAvStreamManagement AttributeList handler, - -- so the subscription needs to be added here first if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) > 0 then - device:add_subscribed_attribute(clusters.CameraAvStreamManagement.attributes.AttributeList) + table.insert(additional_attributes, clusters.CameraAvStreamManagement.attributes.AttributeList) + table.insert(additional_attributes, camera_fields.CameraAVSMFeatureMapAttr) end - -- Add subscription for attributes specific to child devices - if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then - for _, ep in ipairs(device.endpoints or {}) do - local id = 0 - for _, dt in ipairs(ep.device_types or {}) do - if dt.device_type_id ~= fields.DEVICE_TYPE_ID.GENERIC_SWITCH then - id = math.max(id, dt.device_type_id) - end - end - for _, attr in pairs(fields.device_type_attribute_map[id] or {}) do - device:add_subscribed_attribute(attr) - end - end + if #device:get_endpoints(clusters.CameraAvSettingsUserLevelManagement.ID) > 0 then + table.insert(additional_attributes, camera_fields.CameraAVSULMFeatureMapAttr) end - local im = require "st.matter.interaction_model" - local subscribed_attributes = device:get_field("__subscribed_attributes") or {} - local subscribed_events = device:get_field("__subscribed_events") or {} - local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) - for _, attributes in pairs(subscribed_attributes) do - for _, ib in pairs(attributes) do - subscribe_request:with_info_block(ib) + if #device:get_endpoints(clusters.ZoneManagement.ID) > 0 then + table.insert(additional_attributes, camera_fields.ZoneManagementFeatureMapAttr) + end + + for _, endpoint_info in ipairs(device.endpoints) do + local checked_device = switch_utils.find_child(device, endpoint_info.endpoint_id) or device + if not devices_seen[checked_device.id] then + switch_utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, + camera_subscribed_attributes, camera_subscribed_events + ) + devices_seen[checked_device.id] = true -- only loop through any device once end end - for _, events in pairs(subscribed_events) do - for _, ib in pairs(events) do + + for _, attr in ipairs(additional_attributes) do + local cluster_id = attr.cluster or attr._cluster.ID + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib end end + if #subscribe_request.info_blocks > 0 then device:send(subscribe_request) end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua index e8d5aad8fa..2aa698a08a 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua @@ -6,7 +6,6 @@ ------------------------------------------------------------------------------------- local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" -local button_cfg = require("switch_utils.device_configuration").ButtonCfg local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" local camera_fields = require "sub_drivers.camera.camera_utils.fields" local camera_utils = require "sub_drivers.camera.camera_utils.utils" @@ -33,7 +32,7 @@ end function CameraLifecycleHandlers.do_configure(driver, device) camera_utils.update_camera_component_map(device) if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0 then - camera_cfg.match_profile(device, false, false) + camera_cfg.match_profile(device) end camera_cfg.create_child_devices(driver, device) camera_cfg.initialize_camera_capabilities(device) @@ -42,17 +41,20 @@ end function CameraLifecycleHandlers.driver_switched(driver, device) camera_utils.update_camera_component_map(device) if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0 then - camera_cfg.match_profile(device, false, false) + camera_cfg.match_profile(device) end + device:try_update_metadata({provisioning_state = "PROVISIONED"}) end function CameraLifecycleHandlers.info_changed(driver, device, event, args) - if camera_utils.profile_changed(device.profile.components, args.old_st_store.profile.components) then - camera_cfg.initialize_camera_capabilities(device) - if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.DOORBELL) > 0 then - button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) - end - device:subscribe() + local software_version_changed = device.matter_version ~= nil and args.old_st_store.matter_version ~= nil and + device.matter_version.software ~= args.old_st_store.matter_version.software + local profile_changed = not switch_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) + + if software_version_changed then + camera_cfg.reconcile_profile_and_capabilities(device) + elseif profile_changed then + camera_cfg.reinitialize_changed_camera_capabilities_and_subscriptions(device, args.old_st_store.profile, device.profile) end end @@ -98,7 +100,8 @@ local camera_handler = { [clusters.CameraAvStreamManagement.attributes.Viewport.ID] = attribute_handlers.viewport_handler, [clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.localMediaStorage.localSnapshotRecording), [clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.localMediaStorage.localVideoRecording), - [clusters.CameraAvStreamManagement.attributes.AttributeList.ID] = attribute_handlers.camera_av_stream_management_attribute_list_handler + [clusters.CameraAvStreamManagement.attributes.AttributeList.ID] = attribute_handlers.camera_av_stream_management_attribute_list_handler, + [camera_fields.CameraAVSMFeatureMapAttr.ID] = attribute_handlers.camera_feature_map_handler }, [clusters.CameraAvSettingsUserLevelManagement.ID] = { [clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition.ID] = attribute_handlers.ptz_position_handler, @@ -108,7 +111,9 @@ local camera_handler = { [clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.panRange, camera_fields.pt_range_fields[camera_fields.PAN_IDX].max), [clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.panRange, camera_fields.pt_range_fields[camera_fields.PAN_IDX].min), [clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.tiltRange, camera_fields.pt_range_fields[camera_fields.TILT_IDX].max), - [clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.tiltRange, camera_fields.pt_range_fields[camera_fields.TILT_IDX].min) + [clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin.ID] = attribute_handlers.pt_range_handler_factory(capabilities.mechanicalPanTiltZoom.tiltRange, camera_fields.pt_range_fields[camera_fields.TILT_IDX].min), + [clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams.ID] = attribute_handlers.dptz_streams_handler, + [camera_fields.CameraAVSULMFeatureMapAttr.ID] = attribute_handlers.camera_feature_map_handler }, [clusters.ZoneManagement.ID] = { [clusters.ZoneManagement.attributes.MaxZones.ID] = attribute_handlers.max_zones_handler, @@ -116,6 +121,7 @@ local camera_handler = { [clusters.ZoneManagement.attributes.Triggers.ID] = attribute_handlers.triggers_handler, [clusters.ZoneManagement.attributes.SensitivityMax.ID] = attribute_handlers.sensitivity_max_handler, [clusters.ZoneManagement.attributes.Sensitivity.ID] = attribute_handlers.sensitivity_handler, + [camera_fields.ZoneManagementFeatureMapAttr.ID] = attribute_handlers.camera_feature_map_handler }, [clusters.Chime.ID] = { [clusters.Chime.attributes.InstalledChimeSounds.ID] = attribute_handlers.installed_chime_sounds_handler, 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 369b93b8de..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 @@ -6,12 +6,18 @@ local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" return function(opts, driver, device) - local EVE_MANUFACTURER_ID = 0x130A - -- this sub driver does NOT support child devices, and ONLY supports Eve devices - -- that do NOT support the Electrical Sensor device type + local EVE_PRIVATE_CLUSTER_ID = 0x130AFC01 + -- 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.manufacturer_info.vendor_id == EVE_MANUFACTURER_ID and - #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR) == 0 then + #device:get_endpoints(EVE_PRIVATE_CLUSTER_ID) > 0 and + #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/sub_drivers/eve_energy/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/init.lua index 8222fc1378..59f0cbb9c2 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/eve_energy/init.lua @@ -246,7 +246,9 @@ end local function do_configure(driver, device) end -- override driver_switched to prevent it running in the main driver -local function driver_switched(driver, device) end +local function driver_switched(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end local function handle_refresh(self, device) requestData(device) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/can_handle.lua new file mode 100644 index 0000000000..3ac75a5805 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local device_lib = require "st.device" + local get_product_override_field = require "switch_utils.utils".get_product_override_field + + local checked_device = device.network_type == device_lib.NETWORK_TYPE_MATTER and device or device:get_parent_device() + if get_product_override_field(checked_device, "needs_hager_subdriver") then + return true, require("sub_drivers.hager") + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua new file mode 100644 index 0000000000..8df254bde8 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -0,0 +1,483 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local device_lib = require "st.device" +local buttonCfg = require "switch_utils.device_configuration".ButtonCfg +local create_child = require "switch_utils.device_configuration".ChildCfg.create_or_update_child_devices +local switch_utils = require "switch_utils.utils" +local fields = require "switch_utils.fields" +local version = require "version" + +local ACTIVE_EPS = "__active_EPS" +local MATTER_DEVICE_ID = "MATTER_DEVICE_ID" +local PARENT_ID = "PARENT_ID" +local CURRENT_LIFT = "__current_lift" +local BUTTON_EPS = "__button_eps" +local MAIN_WC_EP = "__main_wc_ep" + +local PRODUCT_ID = { + SWITCH_1G = 0x0005, + SWITCH_2G = 0x0006, + PIR_1_1M = 0x0007, + PIR_2_2M = 0x000A, +} + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +local function subscribe (device, endpoint_id, cluster_id, attr_id, event_id) + device:send(cluster_base.subscribe(device, endpoint_id, cluster_id, attr_id, event_id)) +end + +local function get_parent (driver, device) + return driver:get_device_info(device:get_field(PARENT_ID) or nil) +end + +local function get_matter_device(device) + local matter_device_id = device:get_field(MATTER_DEVICE_ID) + if matter_device_id then + local driver = device.driver + if driver then + local matter_device = driver:get_device_info(matter_device_id) + if matter_device then + return matter_device + end + end + end + return nil +end + +local function extract_reported_endpoints(ib) + local eps = {} + if ib.data and ib.data.elements then + for _, el in ipairs(ib.data.elements) do + local ep_id = el.value + if type(ep_id) == "number" and ep_id ~= 1 and ep_id ~= 2 then + table.insert(eps, ep_id) + end + end + end + return eps +end + +local function detect_endpoint_changes (device, ib_elements) + local stored_eps = device:get_field(ACTIVE_EPS) or {} + ib_elements = ib_elements or {} + + local old_set, new_set = {}, {} + for _, ep_id in ipairs(stored_eps) do + old_set[ep_id] = true + end + for _, ep_id in ipairs(ib_elements) do + new_set[ep_id] = true + end + + local removed_eps, added_eps = {}, {} + + for ep_id in pairs(old_set) do + if not new_set[ep_id] then + table.insert(removed_eps, ep_id) + end + end + for ep_id in pairs(new_set) do + if not old_set[ep_id] then + table.insert(added_eps, ep_id) + end + end + return removed_eps, added_eps +end + +local function assign_profile_for_endpoint(device_type_id) + local profile = fields.device_type_profile_map[device_type_id] or "switch-binary" + return function(device, ep_id, is_child_device) + return profile, nil + end +end + +local function link_matter_device_and_parent(matter_device) + local parent = matter_device:get_parent_device() + matter_device:set_field(PARENT_ID, parent.id, { persist = true }) + matter_device:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(PARENT_ID, parent.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) +end + +local function handle_set_preset(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local lift_value = device.preferences.presetPosition + local hundredths_lift_percent = (100 - tonumber(lift_value)) * 100 + device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage(device, endpoint_id, hundredths_lift_percent)) +end + +local function handle_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + if device.preferences.reverse then + req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + end + device:send(req) +end + +local function handle_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + if device.preferences.reverse then + req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + end + device:send(req) +end + +local function handle_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id) + device:send(req) +end + +local function handle_shade_level(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local lift_percentage_value = 100 - cmd.args.shadeLevel + local hundredths_lift_percentage = lift_percentage_value * 100 + local req = clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percentage + ) + device:send(req) +end + +local current_pos_handler = function(attribute) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local windowShade = capabilities.windowShade.windowShade + local position = 100 - math.floor(ib.data.value / 100) + local reverse = device.preferences.reverse + device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) + if attribute == capabilities.windowShadeLevel.shadeLevel then + device:set_field(CURRENT_LIFT, position) + end + local lift_position = device:get_field(CURRENT_LIFT) + if lift_position == 100 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) + elseif lift_position > 0 then + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) + elseif lift_position == 0 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) + end + end +end + +local function current_status_handler(driver, device, ib, response) + local windowShade = capabilities.windowShade.windowShade + local reverse = device.preferences.reverse + local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL + if state == 1 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closing() or windowShade.opening()) + elseif state == 2 then + device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.opening() or windowShade.closing()) + elseif state ~= 0 then + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) + end +end + +local function info_changed(driver, device, event, args) + if not device or not device.id or not device.profile then + return + end + + if device.profile.id ~= args.old_st_store.profile.id or device.network_type == device_lib.NETWORK_TYPE_CHILD then + 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 = {} + table.sort(button_eps) + for _, v in ipairs(button_eps or {}) do + table.insert(clean_eps, v) + end + + buttonCfg.update_button_component_map(matter_device, clean_eps[1], clean_eps) + for _, ep_id in ipairs(clean_eps) do + subscribe(device, ep_id, clusters.Switch.ID, nil, clusters.Switch.events.MultiPressComplete.ID) + subscribe(device, ep_id, clusters.Switch.ID, nil, clusters.Switch.events.LongPress.ID) + device:emit_event_for_endpoint(ep_id, capabilities.button.supportedButtonValues({ "pushed", "double", "held" })) + end + return + elseif device:supports_capability(capabilities.switch) then + map = { main = 3 } + subscribe(device, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + if device:supports_capability(capabilities.switchLevel) then + map = { main = 4 } + subscribe(device, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + subscribe(device, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID) + subscribe(device, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID) + end + elseif device:supports_capability(capabilities.motionSensor) then + subscribe(device, nil, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil) + subscribe(device, nil, clusters.IlluminanceMeasurement.ID, clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID, nil) + elseif device:supports_capability(capabilities.windowShadeLevel) then + map = { main = 5 } + subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.OperationalStatus.ID) + subscribe(device, nil, clusters.WindowCovering.ID, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID, nil) + end + if device.id == matter_device.id then + parent:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) + matter_device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, map, { persist = true }) + end + end) + end +end + +local function device_init (driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then + device.thread:call_with_delay(4, function() + info_changed(driver, device, nil, { + old_st_store = { profile = { } } + }) + end) + return + end + + if device:get_parent_device() ~= nil then + link_matter_device_and_parent(device) + local parent = device:get_parent_device() + device:extend_device("send", function(self, message) + return parent:send(message) + end) + else + subscribe(device, 2, clusters.Descriptor.ID, clusters.Descriptor.attributes.PartsList.ID) + device.thread:call_with_delay(3, function() + local matter_device = get_matter_device(device) + device:extend_device("emit_event_for_endpoint", function(self, ep_info, event) + local endpoint_id = type(ep_info) == "number" and ep_info or ep_info.endpoint_id + local child = self:get_child_by_parent_assigned_key(string.format("%d", endpoint_id)) + if child then + return child:emit_event(event) + end + if matter_device then + return matter_device:emit_event_for_endpoint(ep_info, event) + end + end) + end) + end + + device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) +end + +local function handle_descriptor_report(driver, device, ib, response) + local product_id = device.manufacturer_info and device.manufacturer_info.product_id + local parent = get_parent(driver, device) + + if not parent or ib.endpoint_id ~= 2 then + return + end + + local matter_device = get_matter_device(device) + + local new_eps = extract_reported_endpoints(ib) or {} + table.sort(new_eps) + local removed_eps, added_eps = detect_endpoint_changes(device, new_eps) + + device:set_field(ACTIVE_EPS, new_eps, { persist = true }) + + for _, ep_id in ipairs(added_eps or {}) do + subscribe(parent, ep_id, clusters.Descriptor.ID, clusters.Descriptor.attributes.DeviceTypeList.ID, nil) + + if product_id == PRODUCT_ID.SWITCH_1G and ep_id == 3 then + matter_device:try_update_metadata({ profile = "light-binary" }) + elseif product_id == PRODUCT_ID.SWITCH_2G then + if ep_id == 3 then + local profile = switch_utils.tbl_contains(new_eps, 4) and "light-binary" or "2-button" + matter_device:try_update_metadata({ profile = profile }) + elseif ep_id == 4 then + local profile = switch_utils.tbl_contains(new_eps, 3) and "light-binary" or "2-button" + matter_device:try_update_metadata({ profile = profile }) + end + end + end + + for _, ep_id in ipairs(removed_eps) do + local button_eps = parent:get_field(BUTTON_EPS) or {} + local clean_eps = {} + + for _, value in ipairs(button_eps) do + if value ~= ep_id then + table.insert(clean_eps, value) + end + end + table.sort(clean_eps) + parent:set_field(BUTTON_EPS, clean_eps, { persist = true }) + matter_device:set_field(BUTTON_EPS, clean_eps, { persist = true }) + + local child = parent:get_child_by_parent_assigned_key(tostring(ep_id)) + if child then + driver:try_delete_device(child.id) + end + + if ep_id == 3 then + if device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then + matter_device:try_update_metadata({ profile = "2-button" }) + elseif device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_2G then + local has_ep4 = switch_utils.tbl_contains(new_eps, 4) + matter_device:try_update_metadata({ profile = has_ep4 and "2-button" or "4-button" }) + end + elseif ep_id == 4 then + if device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_2G then + local button_comb = switch_utils.tbl_contains(new_eps, 3) + if button_comb then + matter_device:try_update_metadata({ profile = "2-button" }) + create_child(driver, parent, { 3 }, 1, assign_profile_for_endpoint(fields.DEVICE_TYPE_ID.LIGHT.ON_OFF)) + else + matter_device:try_update_metadata({ profile = "4-button" }) + end + end + end + end +end + +local function device_type_handler (driver, device, ib) + if version.api < 16 then + clusters.Descriptor.server.attributes.DeviceTypeList:augment_type(ib.data) + end + + local parent = get_parent(driver, device) + if not parent then + return + end + + local matter_device = get_matter_device(device) + local stored_btn_eps = parent:get_field(BUTTON_EPS) or {} + local new_btn_eps = {} + local ep_id = ib.endpoint_id + local value = ib.data.elements + + for _, v in ipairs(stored_btn_eps) do + table.insert(new_btn_eps, v) + end + for _, element in ipairs(value) do + local device_type_field = element.elements.device_type + local device_type_id = device_type_field and device_type_field.value + + if device_type_id == fields.DEVICE_TYPE_ID.GENERIC_SWITCH then + if not switch_utils.tbl_contains(new_btn_eps, ep_id) then + switch_utils.set_field_for_endpoint(parent, fields.SUPPORTS_MULTI_PRESS, ep_id) + switch_utils.set_field_for_endpoint(parent, fields.IGNORE_NEXT_MPC, ep_id) + table.insert(new_btn_eps, ep_id) + table.sort(new_btn_eps) + parent:set_field(BUTTON_EPS, new_btn_eps, { persist = true }) + end + end + + if device_type_id == fields.DEVICE_TYPE_ID.LIGHT.ON_OFF then + local active_eps = device:get_field(ACTIVE_EPS) + + if ep_id == 3 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then + return + elseif ep_id == 4 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_2G then + if switch_utils.tbl_contains(active_eps, 3) then + local ep3 = parent:get_child_by_parent_assigned_key("3") + if ep3 then + driver:try_delete_device(ep3.id) + end + else + create_child(driver, parent, { 4 }, 1, assign_profile_for_endpoint(device_type_id)) + return + end + elseif ep_id == 3 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_2G then + if not switch_utils.tbl_contains(active_eps, 4) then + create_child(driver, parent, { 3 }, 1, assign_profile_for_endpoint(device_type_id)) + else + driver:try_delete_device(ep_id) + end + return + end + create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) + elseif device_type_id == fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE then + if ep_id == 4 and device.manufacturer_info.product_id == PRODUCT_ID.SWITCH_1G then + return + end + create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) + elseif device_type_id == fields.DEVICE_TYPE_ID.WINDOW_COVERING then + if matter_device:get_field(MAIN_WC_EP) == nil then + create_child(driver, parent, { ep_id }, 1, assign_profile_for_endpoint(device_type_id)) + end + end + end +end + +local function do_configure (driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + + local wc_eps = device:get_endpoints(clusters.WindowCovering.ID) + local oc_eps = device:get_endpoints(clusters.OccupancySensing.ID) + local bt_eps = device:get_endpoints(clusters.Switch.ID) + local lvl_eps = device:get_endpoints(clusters.LevelControl.ID) + local product_id = device.manufacturer_info.product_id + + table.sort(wc_eps) + table.sort(oc_eps) + table.sort(lvl_eps) + + if #oc_eps > 0 then + device:try_update_metadata({ profile = "motion-illuminance" }) + elseif #wc_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G then + device:try_update_metadata({ profile = "window-covering" }) + device:set_field(MAIN_WC_EP, wc_eps[1], { persist = true }) + elseif #bt_eps == 4 then + device:try_update_metadata({ profile = "4-button" }) + elseif #bt_eps == 2 then + device:try_update_metadata({ profile = "2-button" }) + elseif #lvl_eps > 0 and product_id == PRODUCT_ID.SWITCH_1G then + device:try_update_metadata({ profile = "light-level" }) + end + device:set_field(BUTTON_EPS, bt_eps, { persist = true }) + end +end + +local function added (driver, device) end +local function driver_switched(driver, device) end + +local hager_switch = { + NAME = "Hager Subdriver", + lifecycle_handlers = { + added = added, + init = device_init, + infoChanged = info_changed, + doConfigure = do_configure, + driverSwitched = driver_switched + }, + matter_handlers = { + attr = { + [clusters.Descriptor.ID] = { + [clusters.Descriptor.attributes.PartsList.ID] = handle_descriptor_report, + [clusters.Descriptor.attributes.DeviceTypeList.ID] = device_type_handler, + }, + [clusters.WindowCovering.ID] = { + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler(capabilities.windowShadeLevel.shadeLevel), + [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, + }, + }, + }, + capability_handlers = { + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = handle_set_preset, + }, + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.close.NAME] = handle_close, + [capabilities.windowShade.commands.open.NAME] = handle_open, + [capabilities.windowShade.commands.pause.NAME] = handle_pause, + }, + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = handle_shade_level, + }, + }, + can_handle = require("sub_drivers.hager.can_handle") +} + +return hager_switch diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua index 6e21a56ac0..6d6410dffa 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua @@ -1,9 +1,11 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local clusters = require "st.matter.clusters" local switch_utils = require "switch_utils.utils" local scroll_utils = require "sub_drivers.ikea_scroll.scroll_utils.utils" local scroll_cfg = require "sub_drivers.ikea_scroll.scroll_utils.device_configuration" +local event_handlers = require "sub_drivers.ikea_scroll.scroll_handlers.event_handlers" local IkeaScrollLifecycleHandlers = {} @@ -23,6 +25,7 @@ end function IkeaScrollLifecycleHandlers.driver_switched(driver, device) scroll_cfg.match_profile(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) end function IkeaScrollLifecycleHandlers.info_changed(driver, device, event, args) @@ -44,6 +47,15 @@ local ikea_scroll_handler = { infoChanged = IkeaScrollLifecycleHandlers.info_changed, init = IkeaScrollLifecycleHandlers.device_init, }, + matter_handlers = { + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = event_handlers.initial_press_handler, + [clusters.Switch.events.MultiPressOngoing.ID] = event_handlers.multi_press_ongoing_handler, + [clusters.Switch.events.MultiPressComplete.ID] = event_handlers.multi_press_complete_handler, + } + } + }, can_handle = require("sub_drivers.ikea_scroll.can_handle") } diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua new file mode 100644 index 0000000000..1fac107a75 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua @@ -0,0 +1,83 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_utils = require "st.utils" +local capabilities = require "st.capabilities" +local switch_utils = require "switch_utils.utils" +local generic_event_handlers = require "switch_handlers.event_handlers" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" +local event_utils = require "sub_drivers.ikea_scroll.scroll_utils.event_utils" + +local IkeaScrollEventHandlers = {} + + +local function rotate_amount_event_helper(device, endpoint_id, num_presses_to_handle) + if num_presses_to_handle <= 0 then return end + + -- to cut down on checks, we can assume that if the endpoint is not in ENDPOINTS_UP_SCROLL, it is in ENDPOINTS_DOWN_SCROLL + local scroll_direction = switch_utils.tbl_contains(scroll_fields.ENDPOINTS_UP_SCROLL, endpoint_id) and 1 or -1 + local scroll_amount = st_utils.clamp_value( + event_utils.rotate_amount_scaling_helper(device, endpoint_id, scroll_direction * scroll_fields.PER_SCROLL_EVENT_ROTATION * num_presses_to_handle), + -100, 100 + ) + + if event_utils.is_valid_scroll_amount(device, scroll_amount) then + device:emit_event_for_endpoint(endpoint_id, capabilities.knob.rotateAmount(scroll_amount, {state_change = true})) + end +end + +-- Used by ENDPOINTS_UP_SCROLL and ENDPOINTS_DOWN_SCROLL, not ENDPOINTS_PUSH +function IkeaScrollEventHandlers.multi_press_ongoing_handler(driver, device, ib, response) + if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then + device.log.debug("Received MultiPressOngoing event from push endpoint, ignoring.") + else + local cur_num_presses_counted = ib.data.elements and ib.data.elements.current_number_of_presses_counted.value or 0 + local cur_multi_press_count = cur_num_presses_counted + if #response.info_blocks > 1 then + -- note: keep in mind that response blocks with mutliple info blocks are not supported by unit tests today. + if event_utils.is_last_valid_info_block(ib.event_id, cur_num_presses_counted, response.info_blocks) then + local aggregated_presses = event_utils.aggregate_scroll_amount_for_info_blocks(device, response.info_blocks) or {} + cur_num_presses_counted = aggregated_presses.total_presses or 0 + cur_multi_press_count = aggregated_presses.presses_in_current_chain or 0 + else + device.log.debug("Received MultiPressOngoing event that is not the last valid info block, ignoring.") + return + end + end + local num_presses_to_handle = cur_num_presses_counted - (device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED) or 0) + device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED, cur_multi_press_count) + rotate_amount_event_helper(device, ib.endpoint_id, num_presses_to_handle) + end +end + +function IkeaScrollEventHandlers.multi_press_complete_handler(driver, device, ib, response) + if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then + generic_event_handlers.multi_press_complete_handler(driver, device, ib, response) + else + local total_num_presses_counted = ib.data.elements and ib.data.elements.total_number_of_presses_counted.value or 0 + if #response.info_blocks > 1 then + -- note: keep in mind that response blocks with mutliple info blocks are not supported by unit tests today. + if event_utils.is_last_valid_info_block(ib.event_id, total_num_presses_counted, response.info_blocks) then + local aggregated_presses = event_utils.aggregate_scroll_amount_for_info_blocks(device, response.info_blocks) or {} + total_num_presses_counted = aggregated_presses.total_presses or 0 + else + device.log.debug("Received MultiPressComplete event that is not the last valid info block, ignoring.") + return + end + end + local num_presses_to_handle = total_num_presses_counted - (device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED) or 0) + rotate_amount_event_helper(device, ib.endpoint_id, num_presses_to_handle) + -- always reset the LATEST_NUMBER_OF_PRESSES_HANDLED to nil at the end of a handled MultiPress chain. + device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED, nil) + end +end + +function IkeaScrollEventHandlers.initial_press_handler(driver, device, ib, response) + if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then + generic_event_handlers.initial_press_handler(driver, device, ib, response) + else + device.log.debug("Received InitialPress event from scroll endpoint, ignoring.") + end +end + +return IkeaScrollEventHandlers diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua index cd2cee49ce..0ad8a68826 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua @@ -11,18 +11,20 @@ local IkeaScrollConfiguration = {} function IkeaScrollConfiguration.build_button_component_map(device) local component_map = { - main = scroll_fields.ENDPOINTS_PRESS[1], - group2 = scroll_fields.ENDPOINTS_PRESS[2], - group3 = scroll_fields.ENDPOINTS_PRESS[3], + main = {scroll_fields.ENDPOINTS_PUSH[1], scroll_fields.ENDPOINTS_UP_SCROLL[1], scroll_fields.ENDPOINTS_DOWN_SCROLL[1]}, + group2 = {scroll_fields.ENDPOINTS_PUSH[2], scroll_fields.ENDPOINTS_UP_SCROLL[2], scroll_fields.ENDPOINTS_DOWN_SCROLL[2]}, + group3 = {scroll_fields.ENDPOINTS_PUSH[3], scroll_fields.ENDPOINTS_UP_SCROLL[3], scroll_fields.ENDPOINTS_DOWN_SCROLL[3]}, } device:set_field(switch_fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) end function IkeaScrollConfiguration.configure_buttons(device) - for _, ep in ipairs(scroll_fields.ENDPOINTS_PRESS) do + for _, ep in ipairs(scroll_fields.ENDPOINTS_PUSH) do device:send(clusters.Switch.attributes.MultiPressMax:read(device, ep)) switch_utils.set_field_for_endpoint(device, switch_fields.SUPPORTS_MULTI_PRESS, ep, true, {persist = true}) - device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = false})) + end + for _, ep in ipairs(scroll_fields.ENDPOINTS_UP_SCROLL) do -- and by extension, ENDPOINTS_DOWN_SCROLL + device:emit_event_for_endpoint(ep, capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}})) end end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua new file mode 100644 index 0000000000..d5568d2c9f --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua @@ -0,0 +1,90 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_utils = require "st.utils" +local clusters = require "st.matter.clusters" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" + +local IkeaScrollEventUtils = {} + + +function IkeaScrollEventUtils.requeue_clear_scroll_state(device) + -- cancel any previously queued clear state actions to prevent unintended clears + if device:get_field(scroll_fields.CLEAR_STATE_TIMER) then + device.thread:cancel_timer(device:get_field(scroll_fields.CLEAR_STATE_TIMER)) + end + local new_timer = device.thread:call_with_delay(scroll_fields.CLEAR_STATE_DELAY_S, function() + device:set_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE, 0) + end) + device:set_field(scroll_fields.CLEAR_STATE_TIMER, new_timer) +end + +function IkeaScrollEventUtils.is_valid_scroll_amount(device, scroll_amount) + local global_rotate_amount_state = device:get_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE) or 0 + local is_rotate_amount_state_at_bounds = (scroll_amount < 0 and global_rotate_amount_state <= -100) or (scroll_amount > 0 and global_rotate_amount_state >= 100) + if is_rotate_amount_state_at_bounds then + return false + end + + device:set_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE, st_utils.clamp_value(global_rotate_amount_state + scroll_amount, -100, 100)) + IkeaScrollEventUtils.requeue_clear_scroll_state(device) + return true +end + +-- inspect all info blocks to find the last one that is not an InitialPress event. We will +-- only try to emit a rotateAmount event if the current info block being handled is that last one. +function IkeaScrollEventUtils.is_last_valid_info_block(cur_info_block_event_id, cur_info_block_value, info_blocks) + local last_valid_emission_idx = #info_blocks + -- Ignore all InitialPress events in a multi-response block + while (last_valid_emission_idx > 1) and (info_blocks[last_valid_emission_idx].info_block.event_id == clusters.Switch.events.InitialPress.ID) do + last_valid_emission_idx = last_valid_emission_idx - 1 + end + + -- Because the info block does not include the unique_key Matter defined event number, this + -- logic is a best guess at matching the current info block to the last valid info block. + local emission_ib = info_blocks[last_valid_emission_idx].info_block + if emission_ib.event_id ~= cur_info_block_event_id then + return false + elseif emission_ib.event_id == clusters.Switch.events.MultiPressComplete.ID then + local last_valid_ib_value = emission_ib.data.elements and emission_ib.data.elements.total_number_of_presses_counted.value or 0 + return last_valid_ib_value == cur_info_block_value + elseif emission_ib.event_id == clusters.Switch.events.MultiPressOngoing.ID then + local last_valid_ib_value = emission_ib.data.elements and emission_ib.data.elements.current_number_of_presses_counted.value or 0 + return last_valid_ib_value == cur_info_block_value + elseif last_valid_emission_idx == 1 then -- aka, all ib's are InitialPress + return true + end + + return false +end + +function IkeaScrollEventUtils.aggregate_scroll_amount_for_info_blocks(device, info_blocks) + local total_presses = 0 + local presses_in_current_chain = 0 + for _, ib in ipairs(info_blocks) do + if ib.info_block.event_id == clusters.Switch.events.MultiPressOngoing.ID then + presses_in_current_chain = ib.info_block.data.elements and ib.info_block.data.elements.current_number_of_presses_counted.value or 0 + elseif ib.info_block.event_id == clusters.Switch.events.MultiPressComplete.ID then + total_presses = total_presses + (ib.info_block.data.elements and ib.info_block.data.elements.total_number_of_presses_counted.value or 0) + presses_in_current_chain = 0 + end + end + total_presses = total_presses + presses_in_current_chain -- aggregate any presses to the total from the current chain + return { total_presses = total_presses, presses_in_current_chain = presses_in_current_chain } +end + +function IkeaScrollEventUtils.rotate_amount_scaling_helper(device, endpoint_id, scroll_amount) + local scale_factor = 1.0 -- the default scale factor is 1.0, i.e none + local component = device:endpoint_to_component(endpoint_id) + if component == "main" then + scale_factor = scroll_fields.KNOB_SENSITIVITY_PREFERENCE_FACTORS[device.preferences.knobSensitivityGroup1] or 1.0 + elseif component == "group2" then + scale_factor = scroll_fields.KNOB_SENSITIVITY_PREFERENCE_FACTORS[device.preferences.knobSensitivityGroup2] or 1.0 + elseif component == "group3" then + scale_factor = scroll_fields.KNOB_SENSITIVITY_PREFERENCE_FACTORS[device.preferences.knobSensitivityGroup3] or 1.0 + end + local scaled_scroll_amount = st_utils.clamp_value(math.floor(scroll_amount * scale_factor), -100, 100) + return scaled_scroll_amount +end + +return IkeaScrollEventUtils diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua index fff0a1cce4..2445e2fd30 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua @@ -8,14 +8,51 @@ local IkeaScrollFields = {} -- PowerSource supported on Root Node IkeaScrollFields.ENDPOINT_POWER_SOURCE = 0 --- Switch Endpoints used for basic press functionality -IkeaScrollFields.ENDPOINTS_PRESS = {3, 6, 9} +-- Generic Switch Endpoints used for basic push functionality +IkeaScrollFields.ENDPOINTS_PUSH = {3, 6, 9} --- Required Events for the ENDPOINTS_PRESS. +-- Generic Switch Endpoints used for Up Scroll functionality +IkeaScrollFields.ENDPOINTS_UP_SCROLL = {1, 4, 7} + +-- Generic Switch Endpoints used for Down Scroll functionality +IkeaScrollFields.ENDPOINTS_DOWN_SCROLL = {2, 5, 8} + +-- Amount to rotate per scroll event +-- 6 == st_utils.round(1/18 * 100), where 18 is the maximum number of presses that can be pressed at a time +IkeaScrollFields.PER_SCROLL_EVENT_ROTATION = 6 + +-- Field to track the latest number of presses handled during a single scroll event sequence +IkeaScrollFields.LATEST_NUMBER_OF_PRESSES_HANDLED = "__latest_number_of_presses_handled" + +-- Field to track the global rotate amount state for the device to ensure no scroll events mapped outside of state bounds are emitted +IkeaScrollFields.GLOBAL_ROTATE_AMOUNT_STATE = "__global_rotate_amount_state" + +-- Stores a timer object, which is required to cancel a timer early +IkeaScrollFields.CLEAR_STATE_TIMER = "__clear_state_timer" + +-- Delay in seconds to wait before clearing the global rotate amount state after the last scroll event +IkeaScrollFields.CLEAR_STATE_DELAY_S = 8 + +-- Amounts by which to multiply the scroll amount based on user preference for sensitivity +IkeaScrollFields.KNOB_SENSITIVITY_PREFERENCE_FACTORS = { + ["0"] = 0.5, -- Low + ["1"] = 1.0, -- Medium + ["2"] = 2.0 -- High +} + +-- Required Events for the ENDPOINTS_PUSH. IkeaScrollFields.switch_press_subscribed_events = { clusters.Switch.events.InitialPress.ID, clusters.Switch.events.MultiPressComplete.ID, clusters.Switch.events.LongPress.ID, } +-- Required Events for the ENDPOINTS_UP_SCROLL and ENDPOINTS_DOWN_SCROLL. Adds a +-- MultiPressOngoing subscription to handle step functionality in real-time +IkeaScrollFields.switch_scroll_subscribed_events = { + clusters.Switch.events.InitialPress.ID, + clusters.Switch.events.MultiPressOngoing.ID, + clusters.Switch.events.MultiPressComplete.ID, +} + return IkeaScrollFields diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua index 67ba2acba5..5e249955e2 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua @@ -4,23 +4,43 @@ local im = require "st.matter.interaction_model" local clusters = require "st.matter.clusters" local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" +local switch_fields = require "switch_utils.fields" local IkeaScrollUtils = {} --- override subscribe function to prevent subscribing to additional events from the main driver +-- override subscribe function in the main driver function IkeaScrollUtils.subscribe(device) local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) - for _, ep_press in ipairs(scroll_fields.ENDPOINTS_PRESS) do + for _, ep_push in ipairs(scroll_fields.ENDPOINTS_PUSH) do for _, switch_event in ipairs(scroll_fields.switch_press_subscribed_events) do - local ib = im.InteractionInfoBlock(ep_press, clusters.Switch.ID, nil, switch_event) + local ib = im.InteractionInfoBlock(ep_push, clusters.Switch.ID, nil, switch_event) subscribe_request:with_info_block(ib) end end + for _, ep_up in ipairs(scroll_fields.ENDPOINTS_UP_SCROLL) do + for _, switch_event in ipairs(scroll_fields.switch_scroll_subscribed_events) do + local ib = im.InteractionInfoBlock(ep_up, clusters.Switch.ID, nil, switch_event) + subscribe_request:with_info_block(ib) + end + end + for _, ep_down in ipairs(scroll_fields.ENDPOINTS_DOWN_SCROLL) do + for _, switch_event in ipairs(scroll_fields.switch_scroll_subscribed_events) do + local ib = im.InteractionInfoBlock(ep_down, clusters.Switch.ID, nil, switch_event) + subscribe_request:with_info_block(ib) + end + end + local cluster_id = clusters.PowerSource.ID + local attr_id = clusters.PowerSource.attributes.BatPercentRemaining.ID local ib = im.InteractionInfoBlock( - scroll_fields.ENDPOINT_POWER_SOURCE, clusters.PowerSource.ID, clusters.PowerSource.attributes.BatPercentRemaining.ID + scroll_fields.ENDPOINT_POWER_SOURCE, cluster_id, attr_id ) subscribe_request:with_info_block(ib) device:send(subscribe_request) + + local subscribed_attrs = device:get_field(switch_fields.SUBSCRIBED_ATTRIBUTES_KEY) or {} + subscribed_attrs[cluster_id] = subscribed_attrs[cluster_id] or {} + subscribed_attrs[cluster_id][attr_id] = ib + device:set_field(switch_fields.SUBSCRIBED_ATTRIBUTES_KEY, subscribed_attrs) end return IkeaScrollUtils \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/can_handle.lua new file mode 100644 index 0000000000..2e1b734406 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local utils = require "switch_utils.utils" + +return function(opts, driver, device) + if utils.get_product_override_field(device, "is_third_reality_garage_door") then + return true, require("sub_drivers.third_reality_garage_door") + end + return false +end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/init.lua new file mode 100644 index 0000000000..4666d091bc --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_garage_door/init.lua @@ -0,0 +1,92 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +------------------------------------------------------------------------------------- +-- Third Reality Garage Door Opener specifics +-- +-- This device uses the OnOff cluster to control the door: +-- OnOff = true -> door open +-- OnOff = false -> door closed +-- Commands are mapped from doorControl capability to OnOff cluster commands. +------------------------------------------------------------------------------------- + +local function device_init(driver, device) + -- Force a subscription to the OnOff cluster, since doorControl does not explicitly map to it in the default driver. + device:add_subscribed_attribute(clusters.OnOff.attributes.OnOff) + device:add_subscribed_attribute(clusters.PowerSource.attributes.BatPercentRemaining) + device:subscribe() +end + +local function match_profile(driver, device) + device:try_update_metadata({profile = "garage-door-battery"}) +end + +-- Prevent any of the main driver's logic from running +local function device_added(driver, device) end + +-- Prevent any of the main driver's logic from running +local function info_changed(driver, device, event, args) end + +local function do_configure(driver, device) + match_profile(driver, device) +end + +local function driver_switched(driver, device) + match_profile(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) +end + + +local function on_off_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.doorControl.door.open()) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.doorControl.door.closed()) + end +end + +local function handle_door_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:emit_event_for_endpoint(endpoint_id, capabilities.doorControl.door.opening()) + device:send(clusters.OnOff.server.commands.On(device, endpoint_id)) +end + +local function handle_door_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:emit_event_for_endpoint(endpoint_id, capabilities.doorControl.door.closing()) + device:send(clusters.OnOff.server.commands.Off(device, endpoint_id)) +end + +local third_reality_garage_door_handler = { + NAME = "ThirdReality Garage Door Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + driverSwitched = driver_switched, + infoChanged = info_changed, + }, + matter_handlers = { + attr = { + [clusters.OnOff.ID] = { + [clusters.OnOff.attributes.OnOff.ID] = on_off_attr_handler, + }, + }, + }, + capability_handlers = { + [capabilities.doorControl.ID] = { + [capabilities.doorControl.commands.open.NAME] = handle_door_open, + [capabilities.doorControl.commands.close.NAME] = handle_door_close, + }, + }, + supported_capabilities = { + capabilities.doorControl, + capabilities.battery, + }, + can_handle = require("sub_drivers.third_reality_garage_door.can_handle") +} + +return third_reality_garage_door_handler diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/can_handle.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/can_handle.lua index f3f5342989..f68e568639 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/can_handle.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/can_handle.lua @@ -1,13 +1,10 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local device_lib = require "st.device" +local utils = require "switch_utils.utils" return function(opts, driver, device) - local THIRD_REALITY_MK1_FINGERPRINT = { vendor_id = 0x1407, product_id = 0x1388 } - if device.network_type == device_lib.NETWORK_TYPE_MATTER and - device.manufacturer_info.vendor_id == THIRD_REALITY_MK1_FINGERPRINT.vendor_id and - device.manufacturer_info.product_id == THIRD_REALITY_MK1_FINGERPRINT.product_id then + if utils.get_product_override_field(device, "is_third_reality_mk1") then return true, require("sub_drivers.third_reality_mk1") end return false diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua index 1572886089..8ba2bd28a1 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/third_reality_mk1/init.lua @@ -36,7 +36,6 @@ local function configure_buttons(device) device.log.info(string.format("Configuring Supported Values for generic switch endpoint %d", ep)) local supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}) device:emit_event_for_endpoint(ep, supportedButtonValues_event) - device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = false})) else device.log.info(string.format("Component not found for generic switch endpoint %d. Skipping Supported Value configuration", ep)) end @@ -82,6 +81,7 @@ end local function driver_switched(driver, device) match_profile(driver, device) + device:try_update_metadata({provisioning_state = "PROVISIONED"}) end local function initial_press_event_handler(driver, device, ib, response) 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 1a36001d6c..46338233a0 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -18,6 +18,11 @@ if version.api < 11 then clusters.PowerTopology = require "embedded_clusters.PowerTopology" end +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + local AttributeHandlers = {} -- [[ ON OFF CLUSTER ATTRIBUTES ]] -- @@ -106,6 +111,9 @@ function AttributeHandlers.current_saturation_handler(driver, device, ib, respon end function AttributeHandlers.color_temperature_mireds_handler(driver, device, ib, response) + if type(device.register_native_capability_attr_handler) == "function" then + device:register_native_capability_attr_handler("colorTemperature", "colorTemperature") + end local temp_in_mired = ib.data.value if temp_in_mired == nil then return @@ -128,18 +136,18 @@ function AttributeHandlers.color_temperature_mireds_handler(driver, device, ib, if device:get_field(fields.IS_PARENT_CHILD_DEVICE) == true then temp_device = switch_utils.find_child(device, ib.endpoint_id) or device end - local most_recent_temp = temp_device:get_field(fields.MOST_RECENT_TEMP) + local latest_requested_kelvin = temp_device:get_field(fields.LATEST_REQUESTED_KELVIN) -- this is to avoid rounding errors from the round-trip conversion of Kelvin to mireds - if most_recent_temp ~= nil and - most_recent_temp <= st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired - 1)) and - most_recent_temp >= st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired + 1)) then - temp = most_recent_temp + if latest_requested_kelvin and + latest_requested_kelvin <= st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired - 1)) and + latest_requested_kelvin >= st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/(temp_in_mired + 1)) then + temp = latest_requested_kelvin end device:emit_event_for_endpoint(ib.endpoint_id, capabilities.colorTemperature.colorTemperature(temp)) end function AttributeHandlers.current_x_handler(driver, device, ib, response) - if device:get_field(fields.COLOR_MODE) == fields.HUE_SAT_COLOR_MODE then + if device:get_field(fields.COLOR_MODE) == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then return end local y = device:get_field(fields.RECEIVED_Y) @@ -157,7 +165,7 @@ function AttributeHandlers.current_x_handler(driver, device, ib, response) end function AttributeHandlers.current_y_handler(driver, device, ib, response) - if device:get_field(fields.COLOR_MODE) == fields.HUE_SAT_COLOR_MODE then + if device:get_field(fields.COLOR_MODE) == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then return end local x = device:get_field(fields.RECEIVED_X) @@ -173,15 +181,17 @@ function AttributeHandlers.current_y_handler(driver, device, ib, response) end function AttributeHandlers.color_mode_handler(driver, device, ib, response) - if ib.data.value == device:get_field(fields.COLOR_MODE) or (ib.data.value ~= fields.HUE_SAT_COLOR_MODE and ib.data.value ~= fields.X_Y_COLOR_MODE) then - return + if ib.data.value == device:get_field(fields.COLOR_MODE) + or (ib.data.value ~= clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION + and ib.data.value ~= clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) then + return end device:set_field(fields.COLOR_MODE, ib.data.value) local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) - if ib.data.value == fields.HUE_SAT_COLOR_MODE then + if ib.data.value == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then req:merge(clusters.ColorControl.attributes.CurrentHue:read()) req:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) - elseif ib.data.value == fields.X_Y_COLOR_MODE then + elseif ib.data.value == clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY then req:merge(clusters.ColorControl.attributes.CurrentX:read()) req:merge(clusters.ColorControl.attributes.CurrentY:read()) end @@ -241,7 +251,7 @@ end -- [[ OCCUPANCY CLUSTER ATTRIBUTES ]] -- function AttributeHandlers.occupancy_handler(driver, device, ib, response) - device:emit_event(ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) + device:emit_event_for_endpoint(ib.endpoint_id, ib.data.value == 0x01 and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) end @@ -314,13 +324,23 @@ end --- In the case there are multiple endpoints supporting the PowerTopology cluster with --- 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_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_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 + + device.log.debug_with({hub_logs=true}, string.format("Handling AvailableEndpoints response for endpoint %d with elements: %s", ib.endpoint_id, st_utils.stringify_table(ib.data.elements or {}))) for i, set_ep_info in pairs(set_topology_eps or {}) do if ib.endpoint_id == set_ep_info.endpoint_id then - -- since EP reponse is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table + -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table switch_utils.remove_field_index(device, fields.ELECTRICAL_SENSOR_EPS, i) local available_endpoints_ids = {} - for _, element in pairs(ib.data.elements) do + for _, element in pairs(ib.data.elements or {}) do table.insert(available_endpoints_ids, element.value) end -- set the required profile elements ("-power", etc.) to one of these EP IDs for later profiling. @@ -329,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 @@ -339,13 +360,23 @@ end -- [[ DESCRIPTOR CLUSTER ATTRIBUTES ]] -- function AttributeHandlers.parts_list_handler(driver, device, ib, response) + if device:get_field(fields.profiling_data.POWER_TOPOLOGY) ~= nil then + 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_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.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 reponse is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table + -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table switch_utils.remove_field_index(device, fields.ELECTRICAL_SENSOR_EPS, i) local associated_endpoints_ids = {} - for _, element in pairs(ib.data.elements) do + for _, element in pairs(ib.data.elements or {}) do table.insert(associated_endpoints_ids, element.value) end -- set the required profile elements ("-power", etc.) to one of these EP IDs for later profiling. @@ -354,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 @@ -380,29 +412,19 @@ function AttributeHandlers.bat_charge_level_handler(driver, device, ib, response end function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) - local profile_name = "" - - local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - for _, attr in ipairs(ib.data.elements) do - -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) or - -- BatChargeLevel (Attribute ID 0x0E) is present. - if attr.value == 0x0C then - profile_name = "button-battery" - break - elseif attr.value == 0x0E then - profile_name = "button-batteryLevel" + local previous_battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) + for _, attr in ipairs(ib.data.elements or {}) do + if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist=true}) break + elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID and + device:get_field(fields.profiling_data.BATTERY_SUPPORT) ~= fields.battery_support.BATTERY_PERCENTAGE then -- don't overwrite if percentage support is already detected + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist=true}) end end - if profile_name ~= "" then - if #button_eps > 1 then - profile_name = string.format("%d-", #button_eps) .. profile_name - end - - if switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then - profile_name = profile_name .. "-temperature-humidity" - end - device:try_update_metadata({ profile = profile_name }) + if not previous_battery_support or previous_battery_support ~= device:get_field(fields.profiling_data.BATTERY_SUPPORT) then + device_cfg.match_profile(driver, device) end end @@ -542,4 +564,79 @@ function AttributeHandlers.percent_current_handler(driver, device, ib, response) device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(ib.data.value)) end + +-- [[ OPERATIONAL STATE CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.operational_state_accepted_command_list_attr_handler(driver, device, ib, response) + if ib.data.elements == nil then return end + local accepted_command_list = {} + for _, accepted_command in ipairs(ib.data.elements) do + local accepted_command_id = accepted_command.value + if fields.operational_state_command_map[accepted_command_id] ~= nil then + table.insert(accepted_command_list, fields.operational_state_command_map[accepted_command_id]) + end + end + local event = capabilities.operationalState.supportedCommands(accepted_command_list, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) +end + +function AttributeHandlers.operational_state_attr_handler(driver, device, ib, response) + if ib.data.value == clusters.OperationalState.types.OperationalStateEnum.STOPPED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.stopped()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.RUNNING then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.running()) + elseif ib.data.value == clusters.OperationalState.types.OperationalStateEnum.PAUSED then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.paused()) + end +end + +function AttributeHandlers.operational_error_attr_handler(driver, device, ib, response) + if ib.data.elements == nil or ib.data.elements.error_state_id == nil or ib.data.elements.error_state_id.value == nil then return end + if version.api < 10 then + clusters.OperationalState.types.ErrorStateStruct:augment_type(ib.data) + end + local operationalError = ib.data.elements.error_state_id.value + if operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_START_OR_RESUME then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToStartOrResume()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.unableToCompleteOperation()) + elseif operationalError == clusters.OperationalState.types.ErrorStateEnum.COMMAND_INVALID_IN_STATE then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.operationalState.operationalState.commandInvalidInCurrentState()) + end +end + + +-- [[ FLOW MEASUREMENT CLUSTER ATTRIBUTES ]] -- + +function AttributeHandlers.flow_attr_handler(driver, device, ib, response) + local measured_value = ib.data.value + if measured_value ~= nil then + local flow = measured_value / 10.0 + local unit = "m^3/h" + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flow({value = flow, unit = unit})) + end +end + +function AttributeHandlers.flow_attr_handler_factory(minOrMax) + return function(driver, device, ib, response) + if ib.data.value == nil then + return + end + local flow_bound = ib.data.value / 10.0 + local unit = "m^3/h" + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..minOrMax, ib.endpoint_id, flow_bound) + local min = switch_utils.get_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MIN, ib.endpoint_id) + local max = switch_utils.get_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MAX, ib.endpoint_id) + if min ~= nil and max ~= nil then + if min < max then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.flowMeasurement.flowRange({ value = { minimum = min, maximum = max }, unit = unit })) + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MIN, ib.endpoint_id, nil) + switch_utils.set_field_for_endpoint(device, fields.FLOW_BOUND_RECEIVED..fields.FLOW_MAX, ib.endpoint_id, nil) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min flow measurement %d that is not lower than the reported max flow measurement %d", min, max)) + end + end + end +end + return AttributeHandlers diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua index f96686b73a..573d2b791b 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/capability_handlers.lua @@ -47,6 +47,21 @@ function CapabilityHandlers.handle_switch_set_level(driver, device, cmd) end +-- [[ STATELESS SWITCH LEVEL STEP CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_step_level(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local step_size = st_utils.round((cmd.args and cmd.args.stepSize or 0)/100.0 * 254) + if step_size == 0 then return end + local endpoint_id = device:component_to_endpoint(cmd.component) + local step_mode = step_size > 0 and clusters.LevelControl.types.StepMode.UP or clusters.LevelControl.types.StepMode.DOWN + local transition_time = device:get_field(fields.TRANSITION_TIME.SWITCH_LEVEL_STEP) or fields.DEFAULT_STEP_TRANSITION_TIME + device:send(clusters.LevelControl.server.commands.Step(device, endpoint_id, step_mode, math.abs(step_size), transition_time, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF)) +end + + -- [[ COLOR CONTROL CAPABILITY COMMANDS ]] -- function CapabilityHandlers.handle_set_color(driver, device, cmd) @@ -56,10 +71,10 @@ function CapabilityHandlers.handle_set_color(driver, device, cmd) if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then local hue = switch_utils.convert_huesat_st_to_matter(cmd.args.color.hue) local sat = switch_utils.convert_huesat_st_to_matter(cmd.args.color.saturation) - req = clusters.ColorControl.server.commands.MoveToHueAndSaturation(device, endpoint_id, hue, sat, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + req = clusters.ColorControl.server.commands.MoveToHueAndSaturation(device, endpoint_id, hue, sat, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) else local x, y, _ = st_utils.safe_hsv_to_xy(cmd.args.color.hue, cmd.args.color.saturation) - req = clusters.ColorControl.server.commands.MoveToColor(device, endpoint_id, x, y, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + req = clusters.ColorControl.server.commands.MoveToColor(device, endpoint_id, x, y, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) end device:send(req) end @@ -69,7 +84,7 @@ function CapabilityHandlers.handle_set_hue(driver, device, cmd) local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then local hue = switch_utils.convert_huesat_st_to_matter(cmd.args.hue) - local req = clusters.ColorControl.server.commands.MoveToHue(device, endpoint_id, hue, 0, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + local req = clusters.ColorControl.server.commands.MoveToHue(device, endpoint_id, hue, 0, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) device:send(req) else device.log.warn("Device does not support huesat features on its color control cluster") @@ -81,7 +96,7 @@ function CapabilityHandlers.handle_set_saturation(driver, device, cmd) local huesat_endpoints = device:get_endpoints(clusters.ColorControl.ID, {feature_bitmap = clusters.ColorControl.FeatureMap.HUE_AND_SATURATION}) if switch_utils.tbl_contains(huesat_endpoints, endpoint_id) then local sat = switch_utils.convert_huesat_st_to_matter(cmd.args.saturation) - local req = clusters.ColorControl.server.commands.MoveToSaturation(device, endpoint_id, sat, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) + local req = clusters.ColorControl.server.commands.MoveToSaturation(device, endpoint_id, sat, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) device:send(req) else device.log.warn("Device does not support huesat features on its color control cluster") @@ -92,23 +107,54 @@ end -- [[ COLOR TEMPERATURE CAPABILITY COMMANDS ]] -- function CapabilityHandlers.handle_set_color_temperature(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end local endpoint_id = device:component_to_endpoint(cmd.component) local temp_in_kelvin = cmd.args.temperature - local min_temp_kelvin = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MIN, endpoint_id) - local max_temp_kelvin = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MAX, endpoint_id) + -- note: the field containing the color temp bounds will be associated with a parent device + local field_device = device:get_parent_device() or device + local min_temp_kelvin = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MIN, endpoint_id) + local max_temp_kelvin = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_KELVIN..fields.COLOR_TEMP_MAX, endpoint_id) local temp_in_mired = st_utils.round(fields.MIRED_KELVIN_CONVERSION_CONSTANT/temp_in_kelvin) if min_temp_kelvin ~= nil and temp_in_kelvin <= min_temp_kelvin then - temp_in_mired = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, endpoint_id) + temp_in_mired = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, endpoint_id) elseif max_temp_kelvin ~= nil and temp_in_kelvin >= max_temp_kelvin then - temp_in_mired = switch_utils.get_field_for_endpoint(device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, endpoint_id) + temp_in_mired = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, endpoint_id) end - local req = clusters.ColorControl.server.commands.MoveToColorTemperature(device, endpoint_id, temp_in_mired, fields.TRANSITION_TIME, fields.OPTIONS_MASK, fields.OPTIONS_OVERRIDE) - device:set_field(fields.MOST_RECENT_TEMP, cmd.args.temperature, {persist = true}) + local req = clusters.ColorControl.server.commands.MoveToColorTemperature(device, endpoint_id, temp_in_mired, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + device:set_field(fields.LATEST_REQUESTED_KELVIN, cmd.args.temperature) device:send(req) end +-- [[ STATELESS COLOR TEMPERATURE STEP CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_step_color_temperature_by_percent(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local step_percent_change = cmd.args and cmd.args.stepSize or 0 + if step_percent_change == 0 then return end + step_percent_change = st_utils.clamp_value(step_percent_change, -100, 100) + local endpoint_id = device:component_to_endpoint(cmd.component) + -- before the Matter 1.3 lua libs update (HUB FW 55), there was no ColorControl StepModeEnum type defined + local step_mode = step_percent_change > 0 and (clusters.ColorControl.types.StepModeEnum and clusters.ColorControl.types.StepModeEnum.DOWN or 3) or (clusters.ColorControl.types.StepModeEnum and clusters.ColorControl.types.StepModeEnum.UP or 1) + -- note: the field containing the color temp bounds will be associated with a parent device + local field_device = device:get_parent_device() or device + local min_mireds = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MIN, endpoint_id) + local max_mireds = switch_utils.get_field_for_endpoint(field_device, fields.COLOR_TEMP_BOUND_RECEIVED_MIRED..fields.COLOR_TEMP_MAX, endpoint_id) + -- since colorTemperatureRange is only set after both custom bounds are, use defaults if any custom bound is missing + if not (min_mireds and max_mireds) then + min_mireds = fields.DEFAULT_MIRED_MIN + max_mireds = fields.DEFAULT_MIRED_MAX + end + local step_size_in_mireds = st_utils.round((max_mireds - min_mireds) * (math.abs(step_percent_change)/100.0)) + local transition_time = device:get_field(fields.TRANSITION_TIME.COLOR_TEMP_STEP) or fields.DEFAULT_STEP_TRANSITION_TIME + device:send(clusters.ColorControl.server.commands.StepColorTemperature(device, endpoint_id, step_mode, step_size_in_mireds, transition_time, min_mireds, max_mireds, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF)) +end + -- [[ VALVE CAPABILITY COMMANDS ]] -- function CapabilityHandlers.handle_valve_open(driver, device, cmd) @@ -189,4 +235,21 @@ function CapabilityHandlers.handle_reset_energy_meter(driver, device, cmd) end end + +-- [[ OPERATIONAL STATE CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.handle_operational_state_resume(driver, device, cmd) + local endpoint_id = device:get_endpoints(clusters.OperationalState.ID)[1] + device:send(clusters.OperationalState.server.commands.Resume(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + +function CapabilityHandlers.handle_operational_state_pause(driver, device, cmd) + local endpoint_id = device:get_endpoints(clusters.OperationalState.ID)[1] + device:send(clusters.OperationalState.server.commands.Pause(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalState:read(device, endpoint_id)) + device:send(clusters.OperationalState.attributes.OperationalError:read(device, endpoint_id)) +end + return CapabilityHandlers diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua index 132cce2ae7..8e5f797877 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -15,24 +15,31 @@ if version.api < 11 then clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" end +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + local DeviceConfiguration = {} local ChildConfiguration = {} local SwitchDeviceConfiguration = {} local ButtonDeviceConfiguration = {} local FanDeviceConfiguration = {} +local ValveDeviceConfiguration = {} -function ChildConfiguration.create_child_devices(driver, device, server_cluster_ep_ids, default_endpoint_id, assign_profile_fn) +function ChildConfiguration.create_or_update_child_devices(driver, device, server_cluster_ep_ids, default_endpoint_id, assign_profile_fn) if #server_cluster_ep_ids == 1 and server_cluster_ep_ids[1] == default_endpoint_id then -- no children will be created - return + return end table.sort(server_cluster_ep_ids) for device_num, ep_id in ipairs(server_cluster_ep_ids) do if ep_id ~= default_endpoint_id then -- don't create a child device that maps to the main endpoint local label_and_name = string.format("%s %d", device.label, device_num) - local child_profile, _ = assign_profile_fn(device, ep_id, true) + local child_profile, optional_component_capabilities = assign_profile_fn(device, ep_id, true) local existing_child_device = device:get_field(fields.IS_PARENT_CHILD_DEVICE) and switch_utils.find_child(device, ep_id) if not existing_child_device then + device.log.info_with({hub_logs=true}, string.format("Creating child device for endpoint %d with profile %s", ep_id, child_profile)) driver:try_create_device({ type = "EDGE_CHILD", label = label_and_name, @@ -43,7 +50,8 @@ function ChildConfiguration.create_child_devices(driver, device, server_cluster_ }) else existing_child_device:try_update_metadata({ - profile = child_profile + profile = child_profile, + optional_component_capabilities = optional_component_capabilities }) end end @@ -74,7 +82,6 @@ function FanDeviceConfiguration.assign_profile_for_fan_ep(device, server_fan_ep_ return "fan-modular", optional_supported_component_capabilities end - function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_onoff_ep_id, is_child_device) local ep_info = switch_utils.get_endpoint_info(device, server_onoff_ep_id) @@ -86,7 +93,9 @@ function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_on local generic_profile = fields.device_type_profile_map[primary_dt_id] local static_electrical_tags = switch_utils.get_field_for_endpoint(device, fields.ELECTRICAL_TAGS, server_onoff_ep_id) - if static_electrical_tags ~= nil then + if type(static_electrical_tags) == "string" then + -- if no associated profile is found for the device type and static electrical tags are available, use "plug-binary" as a fallback + generic_profile = generic_profile or "plug-binary" -- profiles like 'light-binary' and 'plug-binary' should drop the '-binary' and become 'light-power', 'plug-energy-powerConsumption', etc. generic_profile = string.gsub(generic_profile, "-binary", "") .. static_electrical_tags end @@ -99,39 +108,6 @@ function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_on return generic_profile or "switch-binary" end -function SwitchDeviceConfiguration.create_or_update_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id) - if #server_onoff_ep_ids == 1 and server_onoff_ep_ids[1] == default_endpoint_id then -- no children will be created - return - end - - table.sort(server_onoff_ep_ids) - for device_num, ep_id in ipairs(server_onoff_ep_ids) do - if ep_id ~= default_endpoint_id then -- don't create a child device that maps to the main endpoint - local label_and_name = string.format("%s %d", device.label, device_num) - local child_profile = SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, ep_id, true) - local existing_child_device = device:get_field(fields.IS_PARENT_CHILD_DEVICE) and switch_utils.find_child(device, ep_id) - if not existing_child_device then - driver:try_create_device({ - type = "EDGE_CHILD", - label = label_and_name, - profile = child_profile, - parent_device_id = device.id, - parent_assigned_child_key = string.format("%d", ep_id), - vendor_provided_label = label_and_name - }) - else - existing_child_device:try_update_metadata({ - profile = child_profile - }) - end - end - end - - -- Persist so that the find_child function is always set on each driver init. - device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) - device:set_find_child(switch_utils.find_child) -end - -- Per the spec, these attributes are "meant to be changed only during commissioning." function SwitchDeviceConfiguration.set_device_control_options(device) for _, ep in ipairs(device.endpoints) do @@ -156,12 +132,19 @@ function ButtonDeviceConfiguration.update_button_profile(device, default_endpoin if #motion_eps > 0 and (num_button_eps == 3 or num_button_eps == 6) then -- only these two devices are handled profile_name = profile_name .. "-motion" end - local battery_supported = #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 - if battery_supported then -- battery profiles are configured later, in power_source_attribute_list_handler - device:send(clusters.PowerSource.attributes.AttributeList:read(device)) - else - device:try_update_metadata({profile = profile_name}) + local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) + if battery_support == fields.battery_support.BATTERY_PERCENTAGE then + profile_name = profile_name .. "-battery" + elseif battery_support == fields.battery_support.BATTERY_LEVEL then + profile_name = profile_name .. "-batteryLevel" + end + if switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then + profile_name = "3-button-battery-temperature-humidity" end + if switch_utils.get_product_override_field(device, "is_ikea_dual_button") then + profile_name = "ikea-2-button-battery" + end + return profile_name end function ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, button_eps) @@ -181,7 +164,6 @@ function ButtonDeviceConfiguration.update_button_component_map(device, default_e device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) end - function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) local msr_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE}) local msl_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS}) @@ -209,16 +191,62 @@ function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep if supportedButtonValues_event then device:emit_event_for_endpoint(ep, supportedButtonValues_event) end - device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = false})) else device.log.info_with({hub_logs=true}, string.format("Component not found for generic switch endpoint %d. Skipping Supported Value configuration", ep)) end end end +function ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device, irrigation_system_ep_id, is_child_device) + local main_component_capabilities = {} + local profile_name = "irrigation-system" + + local valve_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) + table.sort(valve_ep_ids) + local supports_level = switch_utils.find_cluster_on_ep( + switch_utils.get_endpoint_info(device, is_child_device and irrigation_system_ep_id or valve_ep_ids[1]), + clusters.ValveConfigurationAndControl.ID, + {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL} + ) + if supports_level then + table.insert(main_component_capabilities, capabilities.level.ID) + end + + if is_child_device then + return profile_name, {{"main", main_component_capabilities}} + end + + local irrigation_system_ep_info = switch_utils.get_endpoint_info(device, irrigation_system_ep_id) + if switch_utils.find_cluster_on_ep(irrigation_system_ep_info, clusters.FlowMeasurement.ID) then + table.insert(main_component_capabilities, capabilities.flowMeasurement.ID) + end + if switch_utils.find_cluster_on_ep(irrigation_system_ep_info, clusters.OperationalState.ID) then + table.insert(main_component_capabilities, capabilities.operationalState.ID) + end + + return profile_name, {{"main", main_component_capabilities}} +end + -- [[ PROFILE MATCHING AND CONFIGURATIONS ]] -- +function DeviceConfiguration.match_child_profile(driver, device) + local parent_device = device:get_parent_device() + local irrigation_system_ep_ids = switch_utils.get_endpoints_by_device_type( + parent_device, + fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM + ) + if #irrigation_system_ep_ids > 0 then + ChildConfiguration.create_or_update_child_devices( + driver, + parent_device, + {device:get_endpoint()}, + nil, + ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep + ) + end +end + local function profiling_data_still_required(device) for _, field in pairs(fields.profiling_data) do if device:get_field(field) == nil then @@ -235,28 +263,20 @@ function DeviceConfiguration.match_profile(driver, device) local optional_component_capabilities local updated_profile - if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID) > 0 then - updated_profile = "water-valve" - if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID, - {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then - updated_profile = updated_profile .. "-level" - end - end - local server_onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) -- get_endpoints defaults to return EPs supporting SERVER or BOTH if #server_onoff_ep_ids > 0 then - ChildConfiguration.create_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id, SwitchDeviceConfiguration.assign_profile_for_onoff_ep) + ChildConfiguration.create_or_update_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id, SwitchDeviceConfiguration.assign_profile_for_onoff_ep) end if switch_utils.tbl_contains(server_onoff_ep_ids, default_endpoint_id) then updated_profile = SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, default_endpoint_id) local generic_profile = function(s) return string.find(updated_profile or "", s, 1, true) end if generic_profile("light-level") and #device:get_endpoints(clusters.OccupancySensing.ID) > 0 then - updated_profile = "light-level-motion" - elseif generic_profile("plug-binary") or generic_profile("plug-level") then - if switch_utils.check_switch_category_vendor_overrides(device) then - updated_profile = string.gsub(updated_profile, "plug", "switch") - end + updated_profile = switch_utils.get_product_override_field(device, "target_profile") or "light-level-motion" + elseif switch_utils.check_switch_category_vendor_overrides(device) then + -- check whether the overwrite should be over "plug" or "light" based on the current profile + local overwrite_category = string.find(updated_profile, "plug") and "plug" or "light" + updated_profile = string.gsub(updated_profile, overwrite_category, "switch") elseif generic_profile("light-level-colorTemperature") or generic_profile("light-color-level") then -- ignore attempts to dynamically profile light-level-colorTemperature and light-color-level devices for now, since -- these may lose fingerprinted Kelvin ranges when dynamically profiled. @@ -264,27 +284,39 @@ function DeviceConfiguration.match_profile(driver, device) end end - local fan_device_type_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) - if #fan_device_type_ep_ids > 0 then + local irrigation_system_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM) + local valve_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) + if #irrigation_system_ep_ids > 0 then + updated_profile, optional_component_capabilities = ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device, irrigation_system_ep_ids[1], false) + ChildConfiguration.create_or_update_child_devices(driver, device, valve_ep_ids, default_endpoint_id, ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep) + elseif #valve_ep_ids > 0 then + updated_profile = "water-valve" + if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID, + {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then + updated_profile = updated_profile .. "-level" + end + end + + if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) > 0 then updated_profile, optional_component_capabilities = FanDeviceConfiguration.assign_profile_for_fan_ep(device, default_endpoint_id) - device:set_field(fields.MODULAR_PROFILE_UPDATED, true) end -- initialize the main device card with buttons if applicable - local momemtary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momemtary_switch_ep_ids) then - ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momemtary_switch_ep_ids) + local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momentary_switch_ep_ids) then + updated_profile = ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momentary_switch_ep_ids) -- All button endpoints found will be added as additional components in the profile containing the default_endpoint_id. - ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momemtary_switch_ep_ids) - ButtonDeviceConfiguration.configure_buttons(device, momemtary_switch_ep_ids) - return + ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) + ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) end device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) end return { + ButtonCfg = ButtonDeviceConfiguration, + ChildCfg = ChildConfiguration, DeviceCfg = DeviceConfiguration, SwitchCfg = SwitchDeviceConfiguration, - ButtonCfg = ButtonDeviceConfiguration -} \ No newline at end of file + ValveCfg = ValveDeviceConfiguration +} diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index f5b85e1009..d16214d2fd 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -2,33 +2,23 @@ -- Licensed under the Apache License, Version 2.0 local clusters = require "st.matter.clusters" -local capabilities = require "st.capabilities" -local version = require "version" - --- Include driver-side definitions when lua libs api version is < 11 -if version.api < 11 then - clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" - clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" -end local SwitchFields = {} -SwitchFields.HUE_SAT_COLOR_MODE = clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION -SwitchFields.X_Y_COLOR_MODE = clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY - -SwitchFields.MOST_RECENT_TEMP = "mostRecentTemp" +SwitchFields.LATEST_REQUESTED_KELVIN = "mostRecentTemp" SwitchFields.RECEIVED_X = "receivedX" SwitchFields.RECEIVED_Y = "receivedY" SwitchFields.HUESAT_SUPPORT = "huesatSupport" - SwitchFields.MIRED_KELVIN_CONVERSION_CONSTANT = 1000000 -- These values are a "sanity check" to check that values we are getting are reasonable -local COLOR_TEMPERATURE_KELVIN_MAX = 15000 -local COLOR_TEMPERATURE_KELVIN_MIN = 1000 -SwitchFields.COLOR_TEMPERATURE_MIRED_MAX = SwitchFields.MIRED_KELVIN_CONVERSION_CONSTANT/COLOR_TEMPERATURE_KELVIN_MIN -SwitchFields.COLOR_TEMPERATURE_MIRED_MIN = SwitchFields.MIRED_KELVIN_CONVERSION_CONSTANT/COLOR_TEMPERATURE_KELVIN_MAX +SwitchFields.COLOR_TEMPERATURE_MIRED_MIN = 67 -- 15000 Kelvin +SwitchFields.COLOR_TEMPERATURE_MIRED_MAX = 1000 -- 1000 Kelvin + +-- These values are the config bounds in the default Matter profiles (e.g. light-level-colorTemperature, light-color-level) +SwitchFields.DEFAULT_MIRED_MIN = 154 -- 6500 Kelvin +SwitchFields.DEFAULT_MIRED_MAX = 455 -- 2200 Kelvin SwitchFields.SWITCH_LEVEL_LIGHTING_MIN = 1 SwitchFields.CURRENT_HUESAT_ATTR_MIN = 0 @@ -44,6 +34,9 @@ SwitchFields.DEVICE_TYPE_ID = { ELECTRICAL_SENSOR = 0x0510, FAN = 0x002B, GENERIC_SWITCH = 0x000F, + LIGHT_SENSOR = 0x0106, + IRRIGATION_SYSTEM = 0x0040, + OCCUPANCY_SENSOR = 0x0107, MOUNTED_ON_OFF_CONTROL = 0x010F, MOUNTED_DIMMABLE_LOAD_CONTROL = 0x0110, ON_OFF_PLUG_IN_UNIT = 0x010A, @@ -58,6 +51,8 @@ SwitchFields.DEVICE_TYPE_ID = { DIMMER = 0x0104, COLOR_DIMMER = 0x0105, }, + WATER_VALVE = 0x0042, + WINDOW_COVERING = 0x0202 } SwitchFields.device_type_profile_map = { @@ -72,6 +67,7 @@ SwitchFields.device_type_profile_map = { [SwitchFields.DEVICE_TYPE_ID.DIMMABLE_PLUG_IN_UNIT] = "plug-level", [SwitchFields.DEVICE_TYPE_ID.MOUNTED_ON_OFF_CONTROL] = "switch-binary", [SwitchFields.DEVICE_TYPE_ID.MOUNTED_DIMMABLE_LOAD_CONTROL] = "switch-level", + [SwitchFields.DEVICE_TYPE_ID.WINDOW_COVERING] = "window-covering" } -- COMPONENT_TO_ENDPOINT_MAP is here to preserve the endpoint mapping for @@ -96,26 +92,59 @@ SwitchFields.LEVEL_BOUND_RECEIVED = "__level_bound_received" SwitchFields.LEVEL_MIN = "__level_min" SwitchFields.LEVEL_MAX = "__level_max" SwitchFields.COLOR_MODE = "__color_mode" +SwitchFields.FLOW_BOUND_RECEIVED = "__flow_bound_received" +SwitchFields.FLOW_MIN = "__flow_min" +SwitchFields.FLOW_MAX = "__flow_max" + +SwitchFields.SUBSCRIBED_ATTRIBUTES_KEY = "__subscribed_attributes" SwitchFields.updated_fields = { { current_field_name = "__component_to_endpoint_map_button", updated_field_name = SwitchFields.COMPONENT_TO_ENDPOINT_MAP }, { current_field_name = "__switch_intialized", updated_field_name = nil }, { current_field_name = "__energy_management_endpoint", updated_field_name = nil }, { current_field_name = "__total_imported_energy", updated_field_name = nil }, + { current_field_name = "__last_imported_report_timestamp", updated_field_name = nil }, + { current_field_name = "mostRecentTemp", updated_field_name = nil }, } SwitchFields.vendor_overrides = { - [0x1321] = { -- SONOFF_MANUFACTURER_ID - [0x000C] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, - [0x000D] = { target_profile = "switch-binary", initial_profile = "plug-binary" }, - }, [0x115F] = { -- AQARA_MANUFACTURER_ID [0x1006] = { ignore_combo_switch_button = true }, -- 3 Buttons(Generic Switch), 1 Channel (Dimmable Light) [0x100A] = { ignore_combo_switch_button = true }, -- 1 Buttons(Generic Switch), 1 Channel (Dimmable Light) [0x2004] = { is_climate_sensor_w100 = true }, -- Climate Sensor W100, requires unique profile }, [0x117C] = { -- IKEA_MANUFACTURER_ID - [0x8000] = { is_ikea_scroll = true } + [0x8000] = { is_ikea_scroll = true }, -- BILRESA scroll wheel + [0x8001] = { is_ikea_dual_button = true}, -- BILRESA dual button + }, + [0x1189] = { -- LEDVANCE_MANUFACTURER_ID + [0x0891] = { target_profile = "switch-binary", initial_profile = "light-binary" }, + }, + [0x1285] = { -- HAGER_MANUFACTURER_ID + [0x0005] = { needs_hager_subdriver = true }, -- Hager HBnet 1g switch + [0x0006] = { needs_hager_subdriver = true }, -- Hager HBnet 2g switch + [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" }, + }, + [0x1209] = { -- BOSCH_MANUFACTURER_ID + [0x3013] = {target_profile = "light-level-battery-illuminance-motion-temperature"}, + }, + [0x1407] = { -- THIRD_REALITY_MANUFACTURER_ID + [0x1098] = { is_third_reality_garage_door = true }, + [0x1388] = { is_third_reality_mk1 = true}, }, } @@ -128,6 +157,8 @@ SwitchFields.switch_category_vendor_overrides = { {0x007D, 0x0074, 0x0075}, [0x1372] = -- Innovation Matters {0x0002}, + [0x1189] = -- Ledvance + {0x0891, 0x0892}, [0x1021] = -- Legrand {0x0005}, [0x109B] = -- Leviton @@ -144,18 +175,28 @@ SwitchFields.switch_category_vendor_overrides = { {0xEEE2, 0xAB08, 0xAB31, 0xAB04, 0xAB01, 0xAB43, 0xAB02, 0xAB03, 0xAB05} } +SwitchFields.operational_state_command_map = { + [clusters.OperationalState.commands.Pause.ID] = "pause", + [clusters.OperationalState.commands.Resume.ID] = "resume" +} + --- stores a table of endpoints that support the Electrical Sensor device type, used during profiling --- in AvailableEndpoints and PartsList handlers for SET and TREE PowerTopology features, respectively SwitchFields.ELECTRICAL_SENSOR_EPS = "__electrical_sensor_eps" --- used in tandem with an EP ID. Stores the required electrical tags "-power", "-energy-powerConsumption", etc. ---- for an Electrical Sensor EP with a "primary" endpoint, used during device profling. +--- for an Electrical Sensor EP with a "primary" endpoint, used during device profiling. SwitchFields.ELECTRICAL_TAGS = "__electrical_tags" -SwitchFields.MODULAR_PROFILE_UPDATED = "__modular_profile_updated" - SwitchFields.profiling_data = { POWER_TOPOLOGY = "__power_topology", + BATTERY_SUPPORT = "__battery_support", +} + +SwitchFields.battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE", } SwitchFields.ENERGY_METER_OFFSET = "__energy_meter_offset" @@ -186,125 +227,18 @@ SwitchFields.TEMP_BOUND_RECEIVED = "__temp_bound_received" SwitchFields.TEMP_MIN = "__temp_min" SwitchFields.TEMP_MAX = "__temp_max" -SwitchFields.TRANSITION_TIME = 0 --1/10ths of a second --- When sent with a command, these options mask and override bitmaps cause the command --- to take effect when the switch/light is off. -SwitchFields.OPTIONS_MASK = 0x01 -SwitchFields.OPTIONS_OVERRIDE = 0x01 +SwitchFields.ZERO_TRANSITION_TIME = 0 -- 0.0 seconds +SwitchFields.DEFAULT_STEP_TRANSITION_TIME = 3 -- 0.3 seconds, measured in tenths of a second as per the Matter spec - -SwitchFields.supported_capabilities = { - capabilities.audioMute, - capabilities.audioRecording, - capabilities.audioVolume, - capabilities.battery, - capabilities.batteryLevel, - capabilities.button, - capabilities.cameraPrivacyMode, - capabilities.cameraViewportSettings, - capabilities.colorControl, - capabilities.colorTemperature, - capabilities.energyMeter, - capabilities.fanMode, - capabilities.fanSpeedPercent, - capabilities.hdr, - capabilities.illuminanceMeasurement, - capabilities.imageControl, - capabilities.level, - capabilities.localMediaStorage, - capabilities.mechanicalPanTiltZoom, - capabilities.motionSensor, - capabilities.nightVision, - capabilities.powerMeter, - capabilities.powerConsumptionReport, - capabilities.relativeHumidityMeasurement, - capabilities.sounds, - capabilities.switch, - capabilities.switchLevel, - capabilities.temperatureMeasurement, - capabilities.valve, - capabilities.videoStreamSettings, - capabilities.webrtc, - capabilities.zoneManagement +SwitchFields.TRANSITION_TIME = { + SWITCH_LEVEL_STEP = "__switch_level_step_transition_time", + COLOR_TEMP_STEP = "__color_temp_step_transition_time", } -SwitchFields.device_type_attribute_map = { - [SwitchFields.DEVICE_TYPE_ID.LIGHT.ON_OFF] = { - clusters.OnOff.attributes.OnOff - }, - [SwitchFields.DEVICE_TYPE_ID.LIGHT.DIMMABLE] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel - }, - [SwitchFields.DEVICE_TYPE_ID.LIGHT.COLOR_TEMPERATURE] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds - }, - [SwitchFields.DEVICE_TYPE_ID.LIGHT.EXTENDED_COLOR] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode - }, - [SwitchFields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT] = { - clusters.OnOff.attributes.OnOff - }, - [SwitchFields.DEVICE_TYPE_ID.DIMMABLE_PLUG_IN_UNIT] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel - }, - [SwitchFields.DEVICE_TYPE_ID.SWITCH.ON_OFF_LIGHT] = { - clusters.OnOff.attributes.OnOff - }, - [SwitchFields.DEVICE_TYPE_ID.SWITCH.DIMMER] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel - }, - [SwitchFields.DEVICE_TYPE_ID.SWITCH.COLOR_DIMMER] = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode - }, - [SwitchFields.DEVICE_TYPE_ID.GENERIC_SWITCH] = { - clusters.PowerSource.attributes.BatPercentRemaining, - clusters.Switch.events.InitialPress, - clusters.Switch.events.LongPress, - clusters.Switch.events.ShortRelease, - clusters.Switch.events.MultiPressComplete - }, - [SwitchFields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR] = { - clusters.ElectricalPowerMeasurement.attributes.ActivePower, - clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, - clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported - } -} +-- For Level/Color Control cluster commands, this field indicates which bits in the OptionsOverride field are valid. In this case, we specify that the ExecuteIfOff option (bit 1) may be overridden. +SwitchFields.OPTIONS_MASK = 0x01 +-- the OptionsOverride field's first bit overrides the ExecuteIfOff option, defining whether the command should take effect when the device is off. +SwitchFields.HANDLE_COMMAND_IF_OFF = 0x01 +SwitchFields.IGNORE_COMMAND_IF_OFF = 0x00 -return SwitchFields \ No newline at end of file +return SwitchFields diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 88c846501a..cb95ff770b 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -21,6 +21,11 @@ if version.api < 16 then clusters.Descriptor = require "embedded_clusters.Descriptor" end +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + local utils = {} function utils.tbl_contains(array, value) @@ -48,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 @@ -151,10 +165,6 @@ function utils.find_default_endpoint(device) return device.MATTER_DEFAULT_ENDPOINT end - local onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) - local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - local fan_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) - local get_first_non_zero_endpoint = function(endpoints) table.sort(endpoints) for _,ep in ipairs(endpoints) do @@ -166,23 +176,24 @@ function utils.find_default_endpoint(device) end -- Return the first fan endpoint as the default endpoint if any is found + local fan_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) if #fan_endpoint_ids > 0 then return get_first_non_zero_endpoint(fan_endpoint_ids) end - -- Return the first onoff endpoint as the default endpoint if no momentary switch endpoints are present - if #momentary_switch_ep_ids == 0 and #onoff_ep_ids > 0 then - return get_first_non_zero_endpoint(onoff_ep_ids) - end - - -- Return the first momentary switch endpoint as the default endpoint if no onoff endpoints are present - if #onoff_ep_ids == 0 and #momentary_switch_ep_ids > 0 then - return get_first_non_zero_endpoint(momentary_switch_ep_ids) + -- Return the first water valve endpoint as the default endpoint if any is found + local water_valve_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) + if #water_valve_endpoint_ids > 0 then + return get_first_non_zero_endpoint(water_valve_endpoint_ids) end -- If both onoff and momentary switch endpoints are present, check the device type on the first onoff -- endpoint. If it is not a supported device type, return the first momentary switch endpoint as the - -- default endpoint. + -- default endpoint. Else return the first onoff endpoint as the default endpoint. + -- + -- If only one of the two types of endpoints are present, return the first endpoint of the present type. + local onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) + local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) if #onoff_ep_ids > 0 and #momentary_switch_ep_ids > 0 then local default_endpoint_id = get_first_non_zero_endpoint(onoff_ep_ids) if utils.device_type_supports_button_switch_combination(device, default_endpoint_id) then @@ -191,6 +202,10 @@ function utils.find_default_endpoint(device) device.log.warn("The main switch endpoint does not contain a supported device type for a component configuration with buttons") return get_first_non_zero_endpoint(momentary_switch_ep_ids) end + elseif #onoff_ep_ids > 0 then + return get_first_non_zero_endpoint(onoff_ep_ids) + elseif #momentary_switch_ep_ids > 0 then + return get_first_non_zero_endpoint(momentary_switch_ep_ids) end device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) @@ -207,7 +222,8 @@ end --- An extension of the library function endpoint_to_component, used to support a mapping scheme --- that optionally includes cluster and attribute ids so that multiple components can be mapped ---- to a single endpoint. +--- to a single endpoint. This extension also handles the case that multiple endpoints map to the +--- same component --- --- @param device any a Matter device object --- @param ep_info number|table either an ep_id or a table { endpoint_id, optional(cluster_id), optional(attribute_id) } @@ -220,10 +236,19 @@ function utils.endpoint_to_component(device, ep_info) for component, map_info in pairs(device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {}) do if type(map_info) == "number" and map_info == ep_info.endpoint_id then return component - elseif type(map_info) == "table" and map_info.endpoint_id == ep_info.endpoint_id - and (not map_info.cluster_id or (map_info.cluster_id == ep_info.cluster_id - and (not map_info.attribute_ids or utils.tbl_contains(map_info.attribute_ids, ep_info.attribute_id)))) then - return component + elseif type(map_info) == "table" then + if type(map_info.endpoint_id) == "number" then + map_info = {map_info} + end + for _, ep_map_info in ipairs(map_info) do + if type(ep_map_info) == "number" and ep_map_info == ep_info.endpoint_id then + return component + elseif type(ep_map_info) == "table" and ep_map_info.endpoint_id == ep_info.endpoint_id + and (not ep_map_info.cluster_id or (ep_map_info.cluster_id == ep_info.cluster_id + and (not ep_map_info.attribute_ids or utils.tbl_contains(ep_map_info.attribute_ids, ep_info.attribute_id)))) then + return component + end + end end end return "main" @@ -269,7 +294,8 @@ function utils.find_cluster_on_ep(ep, cluster_id, opts) local clus_has_features = function(cluster, checked_feature) return (cluster.feature_map & checked_feature) == checked_feature end - for _, cluster in ipairs(ep.clusters) do + if type(ep) ~= "table" then return nil end + for _, cluster in ipairs(ep.clusters or {}) do if ((cluster.cluster_id == cluster_id) and (opts.feature_bitmap == nil or clus_has_features(cluster, opts.feature_bitmap)) and ((opts.cluster_type == nil and cluster.cluster_type == "SERVER" or cluster.cluster_type == "BOTH") @@ -315,6 +341,50 @@ function utils.create_multi_press_values_list(size, supportsHeld) return list end +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function utils.deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not utils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return utils.deep_equals(mt_a, mt_b, opts, seen) +end + function utils.detect_bridge(device) return #utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.AGGREGATOR) > 0 end @@ -336,22 +406,23 @@ end function utils.report_power_consumption_to_st_energy(device, endpoint_id, total_imported_energy_wh) local current_time = os.time() - local last_time = device:get_field(fields.LAST_IMPORTED_REPORT_TIMESTAMP) or 0 + local last_time = utils.get_field_for_endpoint(device, fields.LAST_IMPORTED_REPORT_TIMESTAMP, endpoint_id) or 0 -- Ensure that the previous report was sent at least 15 minutes ago if fields.MINIMUM_ST_ENERGY_REPORT_INTERVAL >= (current_time - last_time) then return end - device:set_field(fields.LAST_IMPORTED_REPORT_TIMESTAMP, current_time, { persist = true }) + utils.set_field_for_endpoint(device, fields.LAST_IMPORTED_REPORT_TIMESTAMP, endpoint_id, current_time, { persist = true }) local previous_imported_report = utils.get_latest_state_for_endpoint(device, endpoint_id, capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME, { energy = total_imported_energy_wh }) -- default value if nil + local delta_energy = total_imported_energy_wh - previous_imported_report.energy -- Report the energy consumed during the time interval. The unit of these values should be 'Wh' local epoch_to_iso8601 = function(time) return os.date("!%Y-%m-%dT%H:%M:%SZ", time) end -- Return an ISO-8061 timestamp from UTC device:emit_event_for_endpoint(endpoint_id, capabilities.powerConsumptionReport.powerConsumption({ start = epoch_to_iso8601(last_time), ["end"] = epoch_to_iso8601(current_time - 1), - deltaEnergy = total_imported_energy_wh - previous_imported_report.energy, + deltaEnergy = delta_energy >= 0.0 and delta_energy or total_imported_energy_wh, -- clarifying assumption: a negative delta means the meter was reset energy = total_imported_energy_wh })) end @@ -374,7 +445,7 @@ function utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sens table.sort(associated_endpoint_ids) local primary_associated_ep_id = associated_endpoint_ids[1] -- map the required electrical tags for this electrical sensor EP with the first associated EP ID, used later during profling. - utils.set_field_for_endpoint(device, fields.ELECTRICAL_TAGS, primary_associated_ep_id, tags) + utils.set_field_for_endpoint(device, fields.ELECTRICAL_TAGS, primary_associated_ep_id, tags, {persist = true}) utils.set_field_for_endpoint(device, fields.ASSIGNED_CHILD_KEY, electrical_sensor_ep.endpoint_id, string.format("%d", primary_associated_ep_id), { persist = true }) return true end @@ -388,32 +459,32 @@ function utils.handle_electrical_sensor_info(device) return end - -- check the feature map for the first (or only) Electrical Sensor EP - local endpoint_power_topology_cluster = utils.find_cluster_on_ep(electrical_sensor_eps[1], clusters.PowerTopology.ID) or {} - local endpoint_power_topology_feature_map = endpoint_power_topology_cluster.feature_map or 0 - if clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.SET_TOPOLOGY, endpoint_power_topology_feature_map) then - device:set_field(fields.ELECTRICAL_SENSOR_EPS, electrical_sensor_eps) -- assume any other stored EPs also have a SET topology - local available_eps_req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) -- SET read - for _, ep in ipairs(electrical_sensor_eps) do - available_eps_req:merge(clusters.PowerTopology.attributes.AvailableEndpoints:read(device, ep.endpoint_id)) - end - device:send(available_eps_req) - return - elseif clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, endpoint_power_topology_feature_map) then - device:set_field(fields.ELECTRICAL_SENSOR_EPS, electrical_sensor_eps) -- assume any other stored EPs also have a TREE topology - local parts_list_req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) -- TREE read - for _, ep in ipairs(electrical_sensor_eps) do - parts_list_req:merge(clusters.Descriptor.attributes.PartsList:read(device, ep.endpoint_id)) + -- energy reporting must be handled by a cumulative report, a periodic report, or both attributes simultaneously. + -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. + for _, ep_info in ipairs(electrical_sensor_eps) do + if utils.find_cluster_on_ep(ep_info, clusters.ElectricalEnergyMeasurement.ID, + {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY}) then + device:set_field(fields.CUMULATIVE_REPORTS_SUPPORTED, true) + break end - device:send(parts_list_req) - return - elseif clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, endpoint_power_topology_feature_map) then - -- EP has a NODE topology, so there is only ONE Electrical Sensor EP - device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, {persist=true}) - if utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sensor_eps[1], device:get_endpoints(clusters.OnOff.ID)) == false then - device.log.warn("Electrical Sensor EP with NODE topology found, but no OnOff EPs exist. Electrical Sensor capabilities will not be exposed.") + end + + -- check the feature map for the first (or only) Electrical Sensor EP if the device profiling has not been completed + if device:get_field(fields.profiling_data.POWER_TOPOLOGY) == nil then + local endpoint_power_topology_cluster = utils.find_cluster_on_ep(electrical_sensor_eps[1], clusters.PowerTopology.ID) or {} + local endpoint_power_topology_feature_map = endpoint_power_topology_cluster.feature_map or 0 + if clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.SET_TOPOLOGY, endpoint_power_topology_feature_map) or + clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, endpoint_power_topology_feature_map) then + -- stores a table of endpoints that support the Electrical Sensor device type, used during profiling + -- in AvailableEndpoints and PartsList handlers for SET and TREE PowerTopology features, respectively + device:set_field(fields.ELECTRICAL_SENSOR_EPS, electrical_sensor_eps) + elseif clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, endpoint_power_topology_feature_map) then + -- EP has a NODE topology, so there is only ONE Electrical Sensor EP + device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, {persist=true}) + if utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sensor_eps[1], device:get_endpoints(clusters.OnOff.ID)) == false then + device.log.warn("Electrical Sensor EP with NODE topology found, but no OnOff EPs exist. Electrical Sensor capabilities will not be exposed.") + end end - return end end @@ -433,27 +504,98 @@ function utils.lazy_load_if_possible(sub_driver_name) end end -function utils.update_subscriptions(device) - local default_endpoint_id = utils.find_default_endpoint(device) - -- ensure subscription to all endpoint attributes- including those mapped to child devices - for idx, ep in ipairs(device.endpoints) do - if ep.endpoint_id ~= default_endpoint_id then - local id = 0 - for _, dt in ipairs(ep.device_types) do - id = math.max(id, dt.device_type_id) - end - for _, attr in pairs(fields.device_type_attribute_map[id] or {}) do - if id == fields.DEVICE_TYPE_ID.GENERIC_SWITCH and - attr ~= clusters.PowerSource.attributes.BatPercentRemaining and - attr ~= clusters.PowerSource.attributes.BatChargeLevel then - device:add_subscribed_event(attr) - else - device:add_subscribed_attribute(attr) +--- helper for the switch subscribe override, which adds to a subscribed request for a checked device +--- +--- @param checked_device any a Matter device object, either a parent or child device, so not necessarily the same as device +--- @param subscribe_request table a subscribe request that will be appended to as needed for the device +--- @param capabilities_seen table a list of capabilities that have already been checked by previously handled devices +--- @param attributes_seen table a list of attributes that have already been checked +--- @param events_seen table a list of events that have already been checked +--- @param subscribed_attributes table key-value pairs mapping capability ids to subscribed attributes +--- @param subscribed_events table key-value pairs mapping capability ids to subscribed events +function utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, subscribed_attributes, subscribed_events) + for _, component in pairs(checked_device.st_store.profile.components) do + for _, capability in pairs(component.capabilities) do + if not capabilities_seen[capability.id] then + for _, attr in ipairs(subscribed_attributes[capability.id] or {}) do + local cluster_id = attr.cluster or attr._cluster.ID + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib + end + end + for _, event in ipairs(subscribed_events[capability.id] or {}) do + local cluster_id = event.cluster or event._cluster.ID + local event_id = event.ID or event.event + if not events_seen[cluster_id] or not events_seen[cluster_id][event_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, nil, event_id) + subscribe_request:with_info_block(ib) + events_seen[cluster_id] = events_seen[cluster_id] or {} + events_seen[cluster_id][event_id] = ib + end end + capabilities_seen[capability.id] = true -- only loop through any capability once end end end - device:subscribe() +end + +--- create and send a subscription request by checking all devices, accounting for both parent and child devices +--- +--- @param device any a Matter device object +function utils.subscribe(device) + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + local devices_seen, capabilities_seen, attributes_seen, events_seen = {}, {}, {}, {} + + for _, endpoint_info in ipairs(device.endpoints) do + local checked_device = utils.find_child(device, endpoint_info.endpoint_id) or device + if not devices_seen[checked_device.id] then + utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, + device.driver.subscribed_attributes, device.driver.subscribed_events + ) + devices_seen[checked_device.id] = true -- only loop through any device once + end + end + + -- If the type of battery support has not yet been determined, add the PowerSource AttributeList to the list of + -- subscribed attributes in order to determine which if any battery capability should be used. + if device:get_field(fields.profiling_data.BATTERY_SUPPORT) == nil then + local ib = im.InteractionInfoBlock(nil, clusters.PowerSource.ID, clusters.PowerSource.attributes.AttributeList.ID) + subscribe_request:with_info_block(ib) + attributes_seen[clusters.PowerSource.ID] = attributes_seen[clusters.PowerSource.ID] or {} + attributes_seen[clusters.PowerSource.ID][clusters.PowerSource.attributes.AttributeList.ID] = ib + end + + -- If the power topology of the device has not yet been determined, add the AvailableEndpoints (for SET topology) + -- or PartsList (for TREE topology) attributes to the list of subscribed attributes in order to map the device's electrical endpoints + -- to the proper device card(s). + if device:get_field(fields.profiling_data.POWER_TOPOLOGY) == nil then + local electrical_sensor_eps = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR, { with_info = true }) or {} + local endpoint_power_topology_cluster = utils.find_cluster_on_ep(electrical_sensor_eps[1], clusters.PowerTopology.ID) or {} + if clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.SET_TOPOLOGY, endpoint_power_topology_cluster.feature_map or 0) then + local ib = im.InteractionInfoBlock(nil, clusters.PowerTopology.ID, clusters.PowerTopology.attributes.AvailableEndpoints.ID) + subscribe_request:with_info_block(ib) + attributes_seen[clusters.PowerTopology.ID] = attributes_seen[clusters.PowerTopology.ID] or {} + attributes_seen[clusters.PowerTopology.ID][clusters.PowerTopology.attributes.AvailableEndpoints.ID] = ib + elseif clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, endpoint_power_topology_cluster.feature_map or 0) then + local ib = im.InteractionInfoBlock(nil, clusters.Descriptor.ID, clusters.Descriptor.attributes.PartsList.ID) + subscribe_request:with_info_block(ib) + attributes_seen[clusters.Descriptor.ID] = attributes_seen[clusters.Descriptor.ID] or {} + attributes_seen[clusters.Descriptor.ID][clusters.Descriptor.attributes.PartsList.ID] = ib + end + end + + -- The refresh capability command handler in the lua libs uses this key to determine which attributes to read. Note + -- that only attributes_seen needs to be saved here, and not events_seen, since the refresh handler only checks + -- attributes and not events. + device:set_field(fields.SUBSCRIBED_ATTRIBUTES_KEY, attributes_seen) + + if #subscribe_request.info_blocks > 0 then + device:send(subscribe_request) + end end return utils diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua index 6371c6be44..0e92647a4a 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua @@ -1,14 +1,11 @@ -- Copyright © 2024 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -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 uint32 = require "st.matter.data_types.Uint32" local clusters = require "st.matter.generated.zap_clusters" -local button_attr = capabilities.button.button +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local uint32 = require "st.matter.data_types.Uint32" -- Mock a 3-button device with temperature and humidity sensor local aqara_mock_device = test.mock_device.build_test_matter_device({ @@ -100,19 +97,17 @@ local aqara_mock_device = test.mock_device.build_test_matter_device({ local function configure_buttons() test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 3)}) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", button_attr.pushed({state_change = false}))) test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 4)}) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) test.socket.matter:__expect_send({aqara_mock_device.id, clusters.Switch.attributes.MultiPressMax:read(aqara_mock_device, 5)}) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) end local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(aqara_mock_device) local cluster_subscribe_list = { + clusters.PowerSource.server.attributes.AttributeList, clusters.PowerSource.server.attributes.BatPercentRemaining, clusters.TemperatureMeasurement.attributes.MeasuredValue, clusters.TemperatureMeasurement.attributes.MinMeasuredValue, @@ -132,29 +127,21 @@ local function test_init() end test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "added" }) - test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "init" }) test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "doConfigure" }) - local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() - test.socket.matter:__expect_send({aqara_mock_device.id, read_attribute_list}) - configure_buttons() aqara_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - local device_info_copy = utils.deep_copy(aqara_mock_device.raw_st_data) - device_info_copy.profile.id = "3-button-battery-temperature-humidity" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "infoChanged", device_info_json }) - test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) - configure_buttons() end test.set_test_init_function(test_init) local function update_profile() - test.socket.matter:__queue_receive({aqara_mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data(aqara_mock_device, 6, {uint32(0x0C)})}) + test.socket.matter:__queue_receive({aqara_mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + aqara_mock_device, 6, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + configure_buttons() aqara_mock_device:expect_metadata_update({ profile = "3-button-battery-temperature-humidity" }) end @@ -171,7 +158,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( aqara_mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -193,7 +183,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( aqara_mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 40.00 }, unit = "C" })) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -218,7 +211,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( aqara_mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 41 })) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -234,7 +230,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( aqara_mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -256,7 +255,10 @@ test.register_coroutine_test( clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -278,7 +280,10 @@ test.register_coroutine_test( clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -315,8 +320,11 @@ test.register_coroutine_test( clusters.Switch.events.MultiPressComplete:build_test_event_report(aqara_mock_device, 4, {new_position = 0, total_number_of_presses_counted = 2, previous_position = 1}) } ) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", button_attr.double({state_change = true}))) - end + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", capabilities.button.button.double({state_change = true}))) + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -335,14 +343,17 @@ test.register_coroutine_test( clusters.Switch.events.LongPress:build_test_event_report(aqara_mock_device, 3, {new_position = 1}) } ) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", button_attr.held({state_change = true}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button1", capabilities.button.button.held({state_change = true}))) test.socket.matter:__queue_receive( { aqara_mock_device.id, clusters.Switch.events.LongRelease:build_test_event_report(aqara_mock_device, 3, {previous_position = 0}) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -361,14 +372,17 @@ test.register_coroutine_test( clusters.Switch.events.LongPress:build_test_event_report(aqara_mock_device, 5, {new_position = 1}) } ) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", button_attr.held({state_change = true}))) + test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", capabilities.button.button.held({state_change = true}))) test.socket.matter:__queue_receive( { aqara_mock_device.id, clusters.Switch.events.LongRelease:build_test_event_report(aqara_mock_device, 5, {previous_position = 0}) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -382,9 +396,12 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send( - aqara_mock_device:generate_test_message("button1", button_attr.double({state_change = true})) + aqara_mock_device:generate_test_message("button1", capabilities.button.button.double({state_change = true})) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -400,7 +417,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( aqara_mock_device:generate_test_message("button1", capabilities.button.supportedButtonValues({"pushed", "double", "held"}, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -422,7 +442,10 @@ test.register_coroutine_test( clusters.Switch.events.ShortRelease:build_test_event_report(aqara_mock_device, 4, {previous_position = 0}) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -442,7 +465,10 @@ test.register_coroutine_test( "button3", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -462,8 +488,10 @@ test.register_coroutine_test( "button1", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}}) ) ) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() - diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua index 3c377255ab..ad197cf435 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua @@ -233,7 +233,10 @@ test.register_coroutine_test( "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -265,7 +268,10 @@ test.register_coroutine_test( mock_device_exhausted:generate_test_message("main", cubeFace.cubeFace({value = "face1Up"})) ) end, - { test_init = test_init_exhausted } + { + test_init = test_init_exhausted, + min_api_version = 14 + } ) -- run the tests 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 35f4cd9ce7..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,10 +4,7 @@ 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 button_attr = capabilities.button.button local version = require "version" if version.api < 11 then @@ -16,6 +13,11 @@ if version.api < 11 then clusters.PowerTopology = require "embedded_clusters.PowerTopology" end +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + local aqara_parent_ep = 4 local aqara_child1_ep = 1 local aqara_child2_ep = 2 @@ -147,16 +149,9 @@ local cumulative_report_val_39 = { local function configure_buttons() test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button4", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(aqara_mock_device:generate_test_message("button4", button_attr.pushed({state_change = false}))) end local function test_init() @@ -181,15 +176,12 @@ local function test_init() -- Test added -> doConfigure logic test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "added" }) - test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "init" }) test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "doConfigure" }) configure_buttons() aqara_mock_device:expect_metadata_update({ profile = "4-button" }) aqara_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - -- to test powerConsumptionReport - test.timer.__create_and_queue_test_time_advance_timer(60 * 15, "interval", "create_poll_report_schedule") for _, child in pairs(aqara_mock_children) do test.mock_device.add_test_device(child) @@ -211,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, @@ -267,7 +256,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( aqara_mock_children[aqara_child2_ep]:generate_test_message("main", capabilities.switch.switch.on()) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -344,7 +336,10 @@ test.register_coroutine_test( energy = 39.0 })) ) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() 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 8e3ad613da..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 @@ -15,6 +15,11 @@ if version.api < 11 then clusters.PowerTopology = require "embedded_clusters.PowerTopology" end +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("plug-level-power-energy-powerConsumption.yml"), manufacturer_info = { @@ -107,10 +112,64 @@ 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, clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.PowerTopology.attributes.AvailableEndpoints, } local subscribed_attributes = { clusters.OnOff.attributes.OnOff, @@ -120,6 +179,7 @@ local subscribed_attributes = { clusters.ElectricalPowerMeasurement.attributes.ActivePower, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.PowerTopology.attributes.AvailableEndpoints, } local cumulative_report_val_19 = { @@ -179,11 +239,6 @@ local function test_init() subscribe_request:merge(cluster:subscribe(mock_device)) end end - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - local read_req = clusters.PowerTopology.attributes.AvailableEndpoints:read(mock_device.id, 1) - read_req:merge(clusters.PowerTopology.attributes.AvailableEndpoints:read(mock_device.id, 3)) - test.socket.matter:__expect_send({ mock_device.id, read_req }) - test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) end test.set_test_init_function(test_init) @@ -196,74 +251,19 @@ local function test_init_periodic() subscribe_request:merge(cluster:subscribe(mock_device_periodic)) end end - test.socket.device_lifecycle:__queue_receive({ mock_device_periodic.id, "added" }) - local read_req = clusters.PowerTopology.attributes.AvailableEndpoints:read(mock_device_periodic.id, 1) - test.socket.matter:__expect_send({ mock_device_periodic.id, read_req }) - test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) - test.socket.device_lifecycle:__queue_receive({ mock_device_periodic.id, "init" }) - test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) end -test.register_message_test( - "On command should send the appropriate commands", - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, 2) - } - } - } -) - -test.register_message_test( - "Off command should send the appropriate commands", - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } - } - }, - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "off", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.Off(mock_device, 2) - } - } - } -) +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", @@ -289,6 +289,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 14 } ) @@ -354,7 +357,10 @@ test.register_coroutine_test( energy = 39.0 })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -376,7 +382,10 @@ test.register_coroutine_test( ) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -435,7 +444,10 @@ test.register_coroutine_test( })) ) end, - { test_init = test_init_periodic } + { + test_init = test_init_periodic, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -457,7 +469,10 @@ test.register_coroutine_test( parent_assigned_child_key = string.format("%d", 4) }) end, - { test_init = test_init } + { + test_init = test_init, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -469,7 +484,10 @@ test.register_coroutine_test( test.socket.matter:__queue_receive({ mock_device_periodic.id, clusters.PowerTopology.attributes.AvailableEndpoints:build_test_report_data(mock_device_periodic, 1, {uint32(1)})}) mock_device_periodic:expect_metadata_update({ profile = "plug-energy-powerConsumption" }) end, - { test_init = test_init_periodic } + { + test_init = test_init_periodic, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -529,8 +547,14 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_child:generate_test_message("main", capabilities.energyMeter.energy({ value = 19.0, unit = "Wh" })) ) - -- no powerConsumptionReport will be emitted now, since it has not been 15 minutes since the previous report (even though it was the parent). - + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:00:00Z", + ["end"] = "1970-01-01T00:15:00Z", + deltaEnergy = 0.0, + energy = 19.0 + })) + ) test.wait_for_events() test.mock_time.advance_time(1500) @@ -565,7 +589,7 @@ test.register_coroutine_test( mock_child:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ start = "1970-01-01T00:15:01Z", ["end"] = "1970-01-01T00:40:00Z", - deltaEnergy = 0.0, + deltaEnergy = 1.0, energy = 20.0 })) ) @@ -582,9 +606,19 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 20.0, unit = "Wh" })) ) - -- no powerConsumptionReport will be emitted now, since it has not been 15 minutes since the previous report (even though it was the child). + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ + start = "1970-01-01T00:15:01Z", + ["end"] = "1970-01-01T00:40:00Z", + deltaEnergy = 1.0, + energy = 20.0 + })) + ) end, - { test_init = test_init } + { + test_init = test_init, + min_api_version = 14 + } ) test.register_coroutine_test( @@ -647,12 +681,15 @@ test.register_coroutine_test( mock_device_periodic:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ start = "1970-01-01T00:15:01Z", ["end"] = "1970-01-01T00:48:20Z", - deltaEnergy = -4.0, + deltaEnergy = 19.0, energy = 19.0 })) ) end, - { test_init = test_init_periodic } + { + test_init = test_init_periodic, + min_api_version = 14 + } ) test.register_message_test( @@ -732,6 +769,44 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 + } +) + +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 } ) diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua index b548bb819b..e285c593e2 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua @@ -19,6 +19,11 @@ if version.api < 16 then clusters.Descriptor = require "embedded_clusters.Descriptor" end +-- Catch nil elements errors gracefully without receiving a coroutine error +if version.api < 21 then + clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct = require "embedded_clusters.ElectricalEnergyMeasurement.types.EnergyMeasurementStruct" +end + local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("plug-level-power-energy-powerConsumption.yml"), manufacturer_info = { @@ -87,6 +92,7 @@ local subscribed_attributes = { clusters.ElectricalPowerMeasurement.attributes.ActivePower, clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + clusters.Descriptor.attributes.PartsList, } local cumulative_report_val_19 = { @@ -127,75 +133,10 @@ local function test_init() subscribe_request:merge(cluster:subscribe(mock_device)) end end - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - local read_req = clusters.Descriptor.attributes.PartsList:read(mock_device.id, 1) - read_req:merge(clusters.Descriptor.attributes.PartsList:read(mock_device.id, 3)) - test.socket.matter:__expect_send({ mock_device.id, read_req }) - test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) end test.set_test_init_function(test_init) -test.register_message_test( - "On command should send the appropriate commands", - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, 2) - } - } - } -) - -test.register_message_test( - "Off command should send the appropriate commands", - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } - } - }, - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "off", args = { } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.Off(mock_device, 2) - } - } - } -) - test.register_message_test( "Active power measurement should generate correct messages", { @@ -220,6 +161,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 14 } ) @@ -285,7 +229,10 @@ test.register_coroutine_test( energy = 39.0 })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -308,7 +255,10 @@ test.register_coroutine_test( parent_assigned_child_key = string.format("%d", 4) }) end, - { test_init = test_init } + { + test_init = test_init, + min_api_version = 14 + } ) test.register_message_test( @@ -388,6 +338,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua b/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua index 1598f953a4..f51143b813 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua @@ -15,6 +15,28 @@ local PRIVATE_ATTR_ID_WATT = 0x130A000A local PRIVATE_ATTR_ID_WATT_ACCUMULATED = 0x130A000B local PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT = 0x130A000E +-- Helper function to add get_endpoints method to mock devices +local function add_get_endpoints_to_mock(device) + device.get_endpoints = function(self, cluster_id, opts) + opts = opts or {} + local eps = {} + for _, ep in ipairs(self.endpoints) do + for _, cluster in ipairs(ep.clusters or {}) do + if cluster.cluster_id == cluster_id then + -- Check feature_bitmap if specified + if opts.feature_bitmap == nil or + (cluster.feature_map and (cluster.feature_map & opts.feature_bitmap) == opts.feature_bitmap) then + table.insert(eps, ep.endpoint_id) + break + end + end + end + end + return eps + end + return device +end + local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("power-energy-powerConsumption.yml"), manufacturer_info = { @@ -53,9 +75,10 @@ local mock_device = test.mock_device.build_test_matter_device({ } } }) +add_get_endpoints_to_mock(mock_device) -local mock_device_electrical_sensor = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("power-energy-powerConsumption.yml"), +local mock_eve_device_using_electrical_sensor = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-energy-powerConsumption.yml"), manufacturer_info = { vendor_id = 0x130A, product_id = 0x0050, @@ -112,30 +135,42 @@ local mock_device_electrical_sensor = test.mock_device.build_test_matter_device( } } }) +add_get_endpoints_to_mock(mock_eve_device_using_electrical_sensor) -local function test_init_electrical_sensor() - test.disable_startup_messages() - test.mock_device.add_test_device(mock_device_electrical_sensor) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, - clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, +-- Mock device without Eve Private Cluster (should not match eve_energy sub-driver) +local mock_device_without_private_cluster = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-binary.yml"), + manufacturer_info = { + vendor_id = 0x130A, + product_id = 0x0051, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, --u32 bitmap + } + }, + device_types = { + { device_type_id = 0x010A, device_type_revision = 1 } -- On/Off Plug + } + } } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_electrical_sensor) - for i, clus in ipairs(cluster_subscribe_list) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_electrical_sensor)) end - end - - test.socket.device_lifecycle:__queue_receive({ mock_device_electrical_sensor.id, "added" }) - test.socket.matter:__expect_send({mock_device_electrical_sensor.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_electrical_sensor.id, "init" }) - test.socket.matter:__expect_send({mock_device_electrical_sensor.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_electrical_sensor.id, "doConfigure" }) - mock_device_electrical_sensor:expect_metadata_update({ profile = "plug-energy-powerConsumption" }) - mock_device_electrical_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end +}) +add_get_endpoints_to_mock(mock_device_without_private_cluster) local function test_init() local cluster_subscribe_list = { @@ -153,6 +188,31 @@ local function test_init() end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Eve Energy sub-driver can_handle should return true for devices with Eve Private Cluster and no Electrical Sensor", + function() + local eve_energy_can_handle = require("sub_drivers.eve_energy.can_handle") + local result, sub_driver = eve_energy_can_handle(nil, nil, mock_device) + assert(result == true, "can_handle should return true for Eve device with private cluster and no electrical sensor") + assert(sub_driver ~= nil, "sub_driver should be returned") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Eve Energy sub-driver can_handle should return false for devices without Eve Private Cluster", + function() + local eve_energy_can_handle = require("sub_drivers.eve_energy.can_handle") + local result = eve_energy_can_handle(nil, nil, mock_device_without_private_cluster) + assert(result == false, "can_handle should return false for device without Eve Private Cluster") + end, + { + min_api_version = 14 + } +) + test.register_message_test( "On command should send the appropriate commands", { @@ -180,6 +240,9 @@ test.register_message_test( clusters.OnOff.server.commands.On(mock_device, 1) } } + }, + { + min_api_version = 14 } ) @@ -210,6 +273,9 @@ test.register_message_test( clusters.OnOff.server.commands.Off(mock_device, 1) } } + }, + { + min_api_version = 14 } ) @@ -227,7 +293,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -239,7 +308,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "removed" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -270,7 +342,8 @@ test.register_coroutine_test( test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(60, "interval", "create_poll_schedule") - end + end, + min_api_version = 14 } ) @@ -287,7 +360,10 @@ test.register_coroutine_test( refresh_response:merge(cluster_base.read(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_WATT_ACCUMULATED, nil)) test.socket.matter:__expect_send({ mock_device.id, refresh_response}) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -311,7 +387,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -335,7 +414,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -371,7 +453,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -396,7 +481,10 @@ test.register_coroutine_test( cluster_base.write(mock_device, 0x01, PRIVATE_CLUSTER_ID, PRIVATE_ATTR_ID_ACCUMULATED_CONTROL_POINT, nil, data) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -420,7 +508,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -454,7 +545,8 @@ test.register_coroutine_test( test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(60 * 15, "interval", "create_poll_report_schedule") test.timer.__create_and_queue_test_time_advance_timer(60, "interval", "create_poll_schedule") - end + end, + min_api_version = 14 } ) @@ -491,7 +583,7 @@ local cumulative_report_val_39 = { test.register_coroutine_test( "Cumulative Energy measurement should generate correct messages", function() - local mock_device = mock_device_electrical_sensor + mock_device = mock_eve_device_using_electrical_sensor test.mock_time.advance_time(901) -- move time 15 minutes past 0 (this can be assumed to be true in practice in all cases) test.socket.matter:__queue_receive( @@ -545,7 +637,14 @@ test.register_coroutine_test( energy = 39.0 })) ) - end, { test_init = test_init_electrical_sensor } + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_eve_device_using_electrical_sensor) + end, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua new file mode 100644 index 0000000000..666550dfcf --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_hager_waasys.lua @@ -0,0 +1,2119 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local data_types = require "st.matter.data_types" +local clusters = require "st.matter.clusters" +local cluster_base = require "st.matter.cluster_base" +local descriptor = require "embedded_clusters.Descriptor" +local version = require "version" + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +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({ + label = "Hager G2 4x Button", + profile = t_utils.get_profile_definition("matter-bridge.yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = product_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 = 1, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } + }, + device_types = { { device_type_id = 0x000E, device_type_revision = 1 } } -- AggregateNode + }, + { + endpoint_id = 2, + clusters = { + { + cluster_id = clusters.Descriptor.ID, + cluster_type = "SERVER", + cluster_revision = 1, + } + }, + device_types = { { device_type_id = 0x0039, device_type_revision = 1 } } -- BridgedNode + } + }, + }) +end + +local function create_matter_device(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 4x Button", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + 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 = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + }, + }) +end + +-- Create Hager 2G Relay device with endpoints 3 & 4 (OnOff clusters) +local function create_hager_2g_relay(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 2G Relay", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + 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 } } + }, + { + endpoint_id = 4, + 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 + +-- Create Hager Dimmer device with ONLY dimmable endpoint (3) - no button endpoints +local function create_hager_dimmer_device_1g(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Dimmer", + 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 = 4, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OnOff.attributes.OnOff.ID] = false + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + } + }) +end + +local function create_hager_dimmer_device_2g(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + 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 + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + } + }) +end + +local function create_matter_device_with_window(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 matter device with Window Covering", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0006, + }, + 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 = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, device_type_revision = 1 } } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + attributes = { + [clusters.Switch.attributes.MultiPressMax.ID] = 2 + } + } + }, + device_types = { { device_type_id = 0x003B, 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 + +local function create_hager_pir_device(profile_name, parent) + return test.mock_device.build_test_matter_device({ + label = "Hager G2 PIR with Buttons and Motion/Illuminance/Dimmer", + profile = t_utils.get_profile_definition(profile_name .. ".yml"), + type = "MATTER", + manufacturer_info = { + vendor_id = 0x1285, + product_id = 0x0007, + }, + 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 + } + }, + { + cluster_id = clusters.LevelControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.LevelControl.attributes.CurrentLevel.ID] = 254 + } + } + }, + device_types = { { device_type_id = 0x0101, device_type_revision = 1 } } + }, + { + endpoint_id = 6, + clusters = { + { + cluster_id = clusters.OccupancySensing.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.OccupancySensing.attributes.Occupancy.ID] = 0 + } + } + }, + device_types = { { device_type_id = 0x0107, device_type_revision = 1 } } + }, + { + endpoint_id = 5, + clusters = { + { + cluster_id = clusters.IlluminanceMeasurement.ID, + cluster_type = "SERVER", + cluster_revision = 1, + attributes = { + [clusters.IlluminanceMeasurement.attributes.MeasuredValue.ID] = 0 + } + } + }, + device_types = { { device_type_id = 0x0106, device_type_revision = 1 } } + }, + } + }) +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" }) + test.socket.device_lifecycle:__queue_receive({ parent.id, "init" }) + test.mock_time.advance_time(5) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, 2, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) +end + +local function subscribe_switch_events(parent, button_eps) + for _, ep in ipairs(button_eps) do + test.socket.matter:__expect_send({ + parent.id, + clusters.Switch.events.MultiPressComplete:subscribe(parent, ep) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Switch.events.LongPress:subscribe(parent, ep) + }) + end +end + +local function add_matter_device(matter_device, parent, expected_profile_change) + test.mock_device.add_test_device(matter_device) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "doConfigure" }) + matter_device:expect_metadata_update({ profile = expected_profile_change }) + matter_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "init" }) + + matter_device:set_field(PARENT_ID, parent.id, { persist = true }) + matter_device:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(PARENT_ID, parent.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) +end + +local function add_switch_matter_device(matter_device, parent) + test.mock_device.add_test_device(matter_device) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "doConfigure" }) + matter_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ matter_device.id, "init" }) + + matter_device:set_field(PARENT_ID, parent.id, { persist = true }) + matter_device:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(PARENT_ID, parent.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) + parent:set_field(MATTER_DEVICE_ID, matter_device.id, { persist = true }) +end + +local function button_supported_values (matter_device) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + 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 } })) + 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 +end + +local function configure_parent(device) + test.socket.device_lifecycle:__queue_receive({ device.id, "doConfigure" }) + device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ device.id, "init" }) + test.socket.matter:__expect_send({ + device.id, + cluster_base.subscribe(device, 2, descriptor.ID, descriptor.attributes.PartsList.ID, nil) + }) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + 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 + +local function test_init() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test("Test: 4-Button Device Detection - Profile Changes from matter-bridge to 4-button When Four Button Endpoints Present", function() + test.socket.matter:__set_channel_ordering("relaxed") + add_parent_device(parent) + local matter_device = create_matter_device("matter-bridge", parent) + add_matter_device(matter_device, parent, "4-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + + local matter_device_parent_id = matter_device:get_field(PARENT_ID) + local matter_device_id = matter_device:get_field(MATTER_DEVICE_ID) + local parent_id = parent:get_field(PARENT_ID) + local parent_matter_device_id = parent:get_field(MATTER_DEVICE_ID) + + assert(matter_device_parent_id == parent.id, "link_host_and_subhub 1/4") + assert(matter_device_id == matter_device.id, "link_host_and_subhub 2/4") + assert(parent_id == parent.id, "link_host_and_subhub 3/4") + assert(parent_matter_device_id == matter_device.id, "link_host_and_subhub 4/4") + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x08) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x09) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x0A) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x0B) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 10, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 11, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + +end, + { + 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") + add_parent_device(parent) + local matter_device = create_matter_device("4-button", parent) + add_matter_device(matter_device, parent, "4-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x08) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x09) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x0A) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 0x0B) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 10, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 11, data_types.Array({ + { device_type = 0x000F, revision = 0x0003 } + })) + }) + test.wait_for_events() + + initiate_info_changed(matter_device, "4-button") + subscribe_switch_events(parent, { 8, 9, 10, 11 }) + button_supported_values(matter_device) + test.wait_for_events() + + --Test single press (pushed) on endpoint 8 (button1) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 -- Single press + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) + + --Test double press on endpoint 8 (button1) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 8, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 9 (button2) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 9, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 10 (button3) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 10, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button3", capabilities.button.button.held({ state_change = true }))) + + -- Test press on endpoint 11 (button4) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 1 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.pushed({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 1, + total_number_of_presses_counted = 2 + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.double({ state_change = true }))) + test.socket.matter:__queue_receive({ + matter_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + matter_device, 11, { + new_position = 1, + previous_position = 0, + } + ) + }) + test.socket.capability:__expect_send(matter_device:generate_test_message("button4", capabilities.button.button.held({ state_change = true }))) +end, + { + 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") + add_parent_device(parent) + local matter_device = create_matter_device("4-button", parent) + add_matter_device(matter_device, parent, "4-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(9), + data_types.Uint16(8), + data_types.Uint16(6), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 6) + }) + + test.wait_for_events() + + -- Receive DeviceTypeList report with device type 15 (button) for endpoint 8 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- Receive DeviceTypeList report with device type 256 (OnOff) for endpoint 6 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 6, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "6" + }) + test.wait_for_events() + initiate_info_changed(matter_device, "2-button") + subscribe_switch_events(parent, { 8, 9 }) + test.socket.capability:__expect_send(matter_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + test.socket.capability:__expect_send(matter_device:generate_test_message("button2", capabilities.button.supportedButtonValues({ "pushed", "double", "held" }))) + + test.wait_for_events() + + local child_switch = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-binary.yml"), + device_network_id = string.format("%s:6", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "6" + }) + test.wait_for_events() + initiate_info_changed(child_switch, "light-binary") + + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.attributes.OnOff:subscribe(parent, nil) + }) +end, + { + 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") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + add_parent_device(parent) + local relay = create_hager_2g_relay("light-binary", parent) + add_switch_matter_device(relay, parent) + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + -- Scenario 1: EP3 + EP4 present → light-binary profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + })) + }) + -- + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 3) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 4) + }) + + relay:expect_metadata_update({ profile = "light-binary" }) + relay:expect_metadata_update({ profile = "light-binary" }) + test.wait_for_events() + -- Both endpoints are device type 256 (OnOff) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 3, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) + local child_switch = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-binary.yml"), + device_network_id = string.format("%s:4", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) + + initiate_info_changed(relay, "light-binary") + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.attributes.OnOff:subscribe(parent, nil) + }) + + initiate_info_changed(child_switch, "light-binary") + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.attributes.OnOff:subscribe(parent, nil) + }) + + --Scenario 2: EP4 removed → profile changes to 2-button, child created for EP3 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + + relay:expect_metadata_update({ profile = "2-button" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- Scenario 3: EP4 reappears → profile changes back to light-binary + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 4) + }) + relay:expect_metadata_update({ profile = "light-binary" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) + + -- Scenario 4: EP3 removed → profile changes to 2-button, child created for EP4 + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(4), + data_types.Uint16(12), + data_types.Uint16(13), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 12) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 13) + }) + + relay:expect_metadata_update({ profile = "2-button" }) + + -- Scenario 5: EP3 and EP4 removed → 4-button profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(10), + data_types.Uint16(11), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 10) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 11) + }) + relay:expect_metadata_update({ profile = "4-button" }) + + -- Scenario 6: Only EP4 present → 2-button profile, child created + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(4), + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + + relay:expect_metadata_update({ profile = "2-button" }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 4) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 4, data_types.Array { + { + device_type = data_types.Uint32(256), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-binary", + parent_device_id = parent.id, + parent_assigned_child_key = "4" + }) +end, + { + 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") + + add_parent_device(parent) + local dimmer = create_hager_dimmer_device_2g("2-button", parent) + add_matter_device(dimmer, parent, "2-button") + test.wait_for_events() + configure_parent(parent) + test.wait_for_events() + + --Scenario 1: 2 button endpoints (8, 9) detected → 2-button profile + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(3) + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 3) + }) + dimmer:expect_metadata_update({ profile = "2-button" }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 3, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-level", + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + + -- Create mock child device for EP3 + local child_dimmer = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-level.yml"), + device_network_id = string.format("%s:3", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "3" + }) + initiate_info_changed(child_dimmer, "light-level") + + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent.id, + cluster_base.subscribe(parent, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switch", "on") + + test.socket.matter:__expect_send({ + parent.id, + clusters.OnOff.server.commands.On(child_dimmer, 3) + }) + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 20 } } }) + + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switchLevel", "setLevel") + + test.socket.matter:__expect_send({ + parent.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(child_dimmer, 3, 51, nil, 0, 0) + }) + +end, + { + 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") + add_parent_device(parent) + local dimmer = create_hager_dimmer_device_2g("2-button", parent) + add_matter_device(dimmer, parent, "2-button") + test.wait_for_events() + configure_parent(parent) +end, + { + 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") + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + add_parent_device(parent_1g) + local dimmer = create_hager_dimmer_device_1g("light-level", parent_1g) + add_matter_device(dimmer, parent_1g, "light-level") + test.wait_for_events() + configure_parent(parent_1g) + + -- Send dimmable endpoint 4 detection (device type 257) to trigger profile change and subscriptions + 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(4), + })) + }) + + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_1g, 4) + }) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_1g.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_1g, 4, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + ----subscriptions from device_type_handler + + initiate_info_changed(dimmer, "light-level") + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_1g.id, + cluster_base.subscribe(parent_1g, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MinLevel.ID, nil) + }) + + test.socket.capability:__queue_receive({ dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + dimmer.expect_native_cmd_handler_registration(dimmer, "switch", "on") + + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.OnOff.server.commands.On(dimmer, 4) + }) + test.socket.capability:__queue_receive({ dimmer.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 20 } } }) + + dimmer.expect_native_cmd_handler_registration(dimmer, "switchLevel", "setLevel") + + test.socket.matter:__expect_send({ + parent_1g.id, + clusters.LevelControl.server.commands.MoveToLevelWithOnOff(dimmer, 4, 51, nil, 0, 0) + }) +end, + { + 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") + add_parent_device(parent_pir) + local pir_device = create_hager_pir_device("motion-illuminance", parent_pir) + add_matter_device(pir_device, parent_pir, "motion-illuminance") + test.wait_for_events() + configure_parent(parent_pir) + +end, + { + 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") + add_parent_device(parent_pir) + local pir_device = create_hager_pir_device("motion-illuminance", parent_pir) + add_matter_device(pir_device, parent_pir, "motion-illuminance") + test.wait_for_events() + + configure_parent(parent_pir) + + test.wait_for_events() + + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent_pir, 2, data_types.Array({ + data_types.Uint16(3), + data_types.Uint16(4), + data_types.Uint16(5) + + })) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_pir, 3) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_pir, 4) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent_pir, 5) + }) + + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 4, data_types.Array { + { + device_type = data_types.Uint32(263), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 5, data_types.Array { + { + device_type = data_types.Uint32(262), + revision = data_types.Uint16(1) + } + }) + }) + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent_pir, 3, data_types.Array { + { + device_type = data_types.Uint32(257), + revision = data_types.Uint16(1) + } + }) + }) + + parent_pir:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "light-level", + parent_device_id = parent_pir.id, + parent_assigned_child_key = "3" + }) + 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) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.OccupancySensing.ID, clusters.OccupancySensing.attributes.Occupancy.ID, nil) + }) + + local child_dimmer = test.mock_device.build_test_child_device({ + label = "Hager G2 4x Button 1", + profile = t_utils.get_profile_definition("light-level.yml"), + device_network_id = string.format("%s:3", parent_pir.id), + 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, + cluster_base.subscribe(parent_pir, nil, clusters.OnOff.ID, clusters.OnOff.attributes.OnOff.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.CurrentLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + cluster_base.subscribe(parent_pir, nil, clusters.LevelControl.ID, clusters.LevelControl.attributes.MaxLevel.ID, nil) + }) + test.socket.matter:__expect_send({ + parent_pir.id, + 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, + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(parent_pir, 6, 1) + }) + + test.socket.capability:__expect_send(pir_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + + -- Verify illuminance measurement event + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.IlluminanceMeasurement.attributes.MeasuredValue:build_test_report_data(parent_pir, 5, 21370) + }) + test.socket.capability:__expect_send(pir_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(137))) + + -- Send on command to OnOff endpoint (dimmer) + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switch", "on") + + test.socket.capability:__queue_receive({ child_dimmer.id, { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.matter:__expect_send({ + 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) + }) + 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 } } }) + test.socket.matter:__expect_send({ + parent_pir.id, + clusters.LevelControl.commands.MoveToLevelWithOnOff(parent_pir, 3, 127, nil, 0, 0) + }) + child_dimmer.expect_native_cmd_handler_registration(child_dimmer, "switchLevel", "setLevel") + + -- Verify level via attribute report + test.socket.matter:__queue_receive({ + parent_pir.id, + clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(parent_pir, 3, 127) + }) + 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 + }) + +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") + 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.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 12) + }) + + -- DeviceTypeList report for window covering endpoint (type 514 = Window Covering Device) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = parent.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", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "12" + }) + + test.mock_device.add_test_device(child_wc) + 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) + }) + + -- Open command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "open", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.UpOrOpen(parent, 12) }) + test.wait_for_events() + + -- Verify open state via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x01) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.opening())) + + + -- Close command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "close", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.DownOrClose(parent, 12) }) + + -- Verify close state via attribute report + test.socket.matter:__queue_receive({ + parent.id, + clusters.WindowCovering.attributes.OperationalStatus:build_test_report_data(parent, 12, 0x02) + }) + test.socket.capability:__expect_send(child_wc:generate_test_message("main", capabilities.windowShade.windowShade.closing())) + + -- Pause command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "pause", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.StopMotion(parent, 12) }) + test.wait_for_events() + + -- Set shade level to 50% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 50 } } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 5000, nil, 0, 0) }) + test.wait_for_events() + + -- Verify shade level via attribute report + 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())) + + + -- Set shade level to 100% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 100 } } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 0, nil, 0, 0) }) + test.wait_for_events() + + -- Verify shade level via attribute report + 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.open())) + + -- Set shade level to 0% + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 0 } } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.GoToLiftPercentage(parent, 12, 10000, nil, 0, 0) }) + + -- Verify shade level via attribute report + 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.closed())) + +end, + { + 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") + 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() + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + -- + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 8) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 9) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 12) + }) + test.wait_for_events() + + -- DeviceTypeList reports for button endpoints (type 15 = Generic Switch) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 8, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 9, data_types.Array { + { + device_type = data_types.Uint32(15), + revision = data_types.Uint16(1) + } + }) + }) + + -- DeviceTypeList report for window covering endpoint (type 514 = Window Covering Device) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + + parent:expect_device_create({ + type = "EDGE_CHILD", + label = "Hager G2 4x Button 1", + profile = "window-covering", + parent_device_id = parent.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", parent.id), + parent_device_id = parent.id, + parent_assigned_child_key = "12" + }) + test.mock_device.add_test_device(child_wc) + + 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") + -- + --Send open command - with reverse_polarity true, this should send DownOrClose + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "open", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.DownOrClose(parent, 12) }) + + -- Send close command - with reverse_polarity true, this should send UpOrOpen + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "close", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.UpOrOpen(parent, 12) }) + + -- Send Pause command + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShade", component = "main", command = "pause", args = {} } }) + test.socket.matter:__expect_send({ parent.id, clusters.WindowCovering.commands.StopMotion(parent, 12) }) + + -- Position preset testing + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { presetPosition = "50" } })) + test.socket.device_lifecycle():__queue_receive(child_wc:generate_info_changed({ preferences = { presetPosition = "20" } })) + + test.wait_for_events() + + local PRESET_LEVEL_KEY = child_wc.preferences.presetPosition + assert(PRESET_LEVEL_KEY == "20", " presetPosition is set to 20") + + test.socket.capability:__queue_receive({ child_wc.id, { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } }) + test.socket.matter:__expect_send( + { parent.id, clusters.WindowCovering.server.commands.GoToLiftPercentage(parent, 12, 8000) } + ) + + test.wait_for_events() + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + })) + }) + + test.wait_for_events() + + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.mock_time.advance_time(5) + local BUTTON_EPS_FIELD = parent:get_field(BUTTON_EPS) + assert(BUTTON_EPS_FIELD[1] == 8) + assert(BUTTON_EPS_FIELD[2] == 9) + + test.wait_for_events() + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.PartsList:build_test_report_data(parent, 2, data_types.Array({ + data_types.Uint16(8), + data_types.Uint16(9), + data_types.Uint16(12), + })) + }) + test.socket.matter:__expect_send({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:subscribe(parent, 12) + }) + test.socket.matter:__queue_receive({ + parent.id, + clusters.Descriptor.attributes.DeviceTypeList:build_test_report_data(parent, 12, data_types.Array { + { + device_type = data_types.Uint32(514), + revision = data_types.Uint16(1) + } + }) + }) + child_wc:expect_metadata_update({ profile = "window-covering" }) + initiate_info_changed(child_wc, "window-covering") + + 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) + }) +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) + } + }) + }) + 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() diff --git a/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua b/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua index c560386e37..6b0bfe32a1 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" @@ -122,18 +125,29 @@ local mock_ikea_scroll = test.mock_device.build_test_matter_device({ } }) -local ENDPOINTS_PRESS = { 3, 6, 9 } +local ENDPOINTS_PUSH = { 3, 6, 9 } +local ENDPOINTS_SCROLL = {1, 2, 4, 5, 7, 8} -- the ikea scroll subdriver has overriden subscribe behavior local function ikea_scroll_subscribe() - local CLUSTER_SUBSCRIBE_LIST ={ - clusters.Switch.events.InitialPress, + local CLUSTER_SUBSCRIBE_LIST_PUSH ={ + clusters.Switch.events.InitialPress, clusters.Switch.server.events.LongPress, clusters.Switch.server.events.MultiPressComplete, } - local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_ikea_scroll, ENDPOINTS_PRESS[1]) - for _, ep_press in ipairs(ENDPOINTS_PRESS) do - for _, event in ipairs(CLUSTER_SUBSCRIBE_LIST) do + local CLUSTER_SUBSCRIBE_LIST_SCROLL = { + clusters.Switch.events.InitialPress, + clusters.Switch.server.events.MultiPressOngoing, + clusters.Switch.server.events.MultiPressComplete, + } + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_PUSH[1]:subscribe(mock_ikea_scroll, ENDPOINTS_PUSH[1]) + for _, ep_press in ipairs(ENDPOINTS_PUSH) do + for _, event in ipairs(CLUSTER_SUBSCRIBE_LIST_PUSH) do + subscribe_request:merge(event:subscribe(mock_ikea_scroll, ep_press)) + end + end + for _, ep_press in ipairs(ENDPOINTS_SCROLL) do + for _, event in ipairs(CLUSTER_SUBSCRIBE_LIST_SCROLL) do subscribe_request:merge(event:subscribe(mock_ikea_scroll, ep_press)) end end @@ -142,13 +156,12 @@ local function ikea_scroll_subscribe() end local function expect_configure_buttons() - local button_attr = capabilities.button.button test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 3)}) - test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", button_attr.pushed({state_change = false}))) test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 6)}) - test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group2", button_attr.pushed({state_change = false}))) test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 9)}) - test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group3", button_attr.pushed({state_change = false}))) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group2", capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group3", capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}}))) end local function test_init() @@ -218,7 +231,721 @@ test.register_message_test( message = mock_ikea_scroll:generate_test_message("group3", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Ikea Scroll Positive rotateAmount events on main are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(18, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(18, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }, + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Ikea Scroll Negative rotateAmount events on main are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(-12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(-18, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(-12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(-18, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[2], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }, + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Ikea Scroll Positive rotateAmount events on group2 are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(18, {state_change = true})) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Ikea Scroll Negative rotateAmount events on group2 are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[4], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[4], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(-12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[4], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(-18, {state_change = true})) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Ikea Scroll Positive rotateAmount events on group3 are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(18, {state_change = true})) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Ikea Scroll Negative rotateAmount events on group3 are emitted correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[6], {new_position = 1} + ) + }, + }, + -- ignore InitialPress events during scroll + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[6], {current_number_of_presses_counted = 2, new_position = 2} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(-12, {state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[6], {current_number_of_presses_counted = 5, new_position = 5} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(-18, {state_change = true})) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Ikea Scroll Long Press Push events on main are handled correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_PUSH[1], {new_position = 1} + ) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_PUSH[1], {new_position = 1} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.button.button.held({state_change = true})) + }, + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Ikea Scroll MultiPressComplete Push events on group2 are handled correctly", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_PUSH[2], {new_position = 1} + ) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_PUSH[2], {total_number_of_presses_counted = 1, previous_position = 0} + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.button.button.pushed({state_change = true})) + }, + }, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.capability:__queue_receive( + {mock_ikea_scroll.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + ) + local read_request = clusters.PowerSource.attributes.BatPercentRemaining:read(mock_ikea_scroll, 0) + test.socket.matter:__expect_send({mock_ikea_scroll.id, read_request}) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- Coroutine tests for sensitivity preference logic. +-- KNOB_SENSITIVITY_FACTORS = {0.5, 1.0, 2.0}, indexed by the preference value. +-- nil preference falls back to 1.0 (tested by the message tests above). + +test.register_coroutine_test( + "Ikea Scroll low sensitivity (factor 0.5) scales rotateAmount correctly on main", + function() + test.socket.device_lifecycle():__queue_receive(mock_ikea_scroll:generate_info_changed({ preferences = { knobSensitivityGroup1 = "0" } })) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 1} + ) + }) + + -- MultiPressOngoing count=2 (delta=2) → floor(12 * 0.5) = 6 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 2, new_position = 2} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(6, {state_change = true}))) + + -- MultiPressOngoing count=5 (delta=3) → floor(18 * 0.5) = 9 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 5, new_position = 5} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(9, {state_change = true}))) + + -- MultiPressComplete total=5 (delta=0, no event emitted) + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }) + + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Ikea Scroll high sensitivity (factor 2.0) scales rotateAmount correctly on main", + function() + test.socket.device_lifecycle():__queue_receive(mock_ikea_scroll:generate_info_changed({ preferences = { knobSensitivityGroup1 = "2" } })) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 1} + ) + }) + + -- MultiPressOngoing count=2 (delta=2) → floor(12 * 2.0) = 24 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 2, new_position = 2} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(24, {state_change = true}))) + + -- MultiPressOngoing count=5 (delta=3) → floor(18 * 2.0) = 36 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {current_number_of_presses_counted = 5, new_position = 5} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", + capabilities.knob.rotateAmount(36, {state_change = true}))) + + -- MultiPressComplete total=5 (delta=0, no event emitted) + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[1], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }) + + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Ikea Scroll high sensitivity (factor 2.0) scales rotateAmount correctly on group2", + function() + test.socket.device_lifecycle():__queue_receive(mock_ikea_scroll:generate_info_changed({ preferences = { knobSensitivityGroup2 = "2" } })) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {new_position = 1} + ) + }) + + -- MultiPressOngoing count=2 (delta=2) → floor(12 * 2.0) = 24 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {current_number_of_presses_counted = 2, new_position = 2} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(24, {state_change = true}))) + + -- MultiPressOngoing count=5 (delta=3) → floor(18 * 2.0) = 36 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {current_number_of_presses_counted = 5, new_position = 5} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group2", + capabilities.knob.rotateAmount(36, {state_change = true}))) + + -- MultiPressComplete total=5 (delta=0, no event emitted) + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[3], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }) + + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Ikea Scroll low sensitivity (factor 0.5) scales rotateAmount correctly on group3", + function() + test.socket.device_lifecycle():__queue_receive(mock_ikea_scroll:generate_info_changed({ preferences = { knobSensitivityGroup3 = "0" } })) + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {new_position = 1} + ) + }) + + -- MultiPressOngoing count=2 (delta=2) → floor(12 * 0.5) = 6 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {current_number_of_presses_counted = 2, new_position = 2} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(6, {state_change = true}))) + + -- MultiPressOngoing count=5 (delta=3) → floor(18 * 0.5) = 9 + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressOngoing:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {current_number_of_presses_counted = 5, new_position = 5} + ) + }) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group3", + capabilities.knob.rotateAmount(9, {state_change = true}))) + + -- MultiPressComplete total=5 (delta=0, no event emitted) + test.socket.matter:__queue_receive({ + mock_ikea_scroll.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_ikea_scroll, ENDPOINTS_SCROLL[5], {new_position = 5, total_number_of_presses_counted = 5, previous_position = 0} + ) + }) + + test.wait_for_events() + end, + { + min_api_version = 15 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua b/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua index 24987079fc..efc80d9e99 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua @@ -4,12 +4,8 @@ local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" -local st_utils = require "st.utils" local clusters = require "st.matter.clusters" -local TRANSITION_TIME = 0 -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("light-color-level-illuminance-motion.yml"), @@ -40,7 +36,7 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} }, device_types = { - {device_type_id = 0x010C, device_type_revision = 1} -- ColorTemperatureLight + {device_type_id = 0x010D, device_type_revision = 1} -- Extended Color Light } }, { @@ -64,53 +60,31 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) -local function set_color_mode(device, endpoint, color_mode) - test.socket.matter:__queue_receive({ - device.id, - clusters.ColorControl.attributes.ColorMode:build_test_report_data( - device, endpoint, color_mode) - }) - local read_req - if color_mode == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then - read_req = clusters.ColorControl.attributes.CurrentHue:read() - read_req:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) - else -- color_mode = clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY - read_req = clusters.ColorControl.attributes.CurrentX:read() - read_req:merge(clusters.ColorControl.attributes.CurrentY:read()) - end - test.socket.matter:__expect_send({device.id, read_req}) -end - local function test_init() - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.IlluminanceMeasurement.attributes.MeasuredValue, - clusters.OccupancySensing.attributes.Occupancy - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device)) - end - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.disable_startup_messages() test.mock_device.add_test_device(mock_device) - set_color_mode(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION) end test.set_test_init_function(test_init) -local function test_init_x_y_color_mode() +local switch_fields = require "switch_utils.fields" + +test.register_coroutine_test( + "doConfigure lifecycle event should not re-configure the device profile", + function () + mock_device:set_field(switch_fields.profiling_data.BATTERY_SUPPORT, false, {persist = true}) + mock_device:set_field(switch_fields.profiling_data.POWER_TOPOLOGY, false, {persist = true}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, 1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "init should cause device to subscribe to all appropriate clusters", function() local cluster_subscribe_list = { clusters.OnOff.attributes.OnOff, clusters.LevelControl.attributes.CurrentLevel, @@ -133,497 +107,148 @@ local function test_init_x_y_color_mode() subscribe_request:merge(cluster:subscribe(mock_device)) end end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) - set_color_mode(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) -end -test.register_message_test( - "On command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, 1) - } - } - } + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + end, + { + min_api_version = 15 + } ) test.register_message_test( - "Off command should send the appropriate commands", + "Illuminance reports should generate correct messages", { { - channel = "capability", + channel = "matter", direction = "receive", message = { mock_device.id, - { capability = "switch", component = "main", command = "off", args = { } } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "off" } + clusters.IlluminanceMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 2, 21370) } }, { - channel = "matter", + channel = "capability", direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.Off(mock_device, 1) - } + message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 15 } ) test.register_message_test( - "Set level command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switchLevel", component = "main", command = "setLevel", args = {20,20} } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switchLevel", capability_cmd_id = "setLevel" } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 1, st_utils.round(20/100.0 * 254), 20, 0 ,0) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 1) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(20)) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 1, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } - } - }, - } -) - -test.register_message_test( - "Current level reports should generate appropriate events", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(math.floor((50 / 254.0 * 100) + 0.5))) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } - } - }, - } -) - -local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) -local sat = math.floor((50 * 0xFE) / 100.0 + 0.5) - -test.register_message_test( - "Set color command should send huesat commands when supported", + "Occupancy reports should generate correct messages", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) - } - }, - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 1) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } - } + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) + clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 0) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(50)) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } - } - }, - } -) - -test.register_message_test( - "Set Hue command should send MoveToHue", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHue(mock_device, 1, hue, 0, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, + min_api_version = 15 } ) -test.register_message_test( - "Set Saturation command should send MoveToSaturation", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToSaturation(mock_device, 1, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, +local generic_manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000 } +local generic_matter_version = { hardware = 1, software = 1 } +local root_endpoint = { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } -) +} -test.register_message_test( - "Set color temperature should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, +local mock_device_light_level_motion = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-level-motion.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, 1) + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = 0x0101, device_type_revision = 1} -- Dimmable Light } }, { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 556) + endpoint_id = 2, + clusters = { + {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, + } } -) - -test.register_coroutine_test( - "X and Y color values should report hue and saturation once both have been received", - function() - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.hue(50) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.saturation(72) - ) - ) - end, - { test_init = test_init_x_y_color_mode } -) +}) -test.register_coroutine_test( - "X and Y color values have 0 value", - function() - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 0) - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 0) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.hue(33) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.saturation(100) - ) - ) - end, - { test_init = test_init_x_y_color_mode } -) +local function test_init_light_level_motion() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_light_level_motion) +end test.register_coroutine_test( - "Y and X color values should report hue and saturation once both have been received", + "Test init and doConfigure for Dimmable Light device type with Occupancy Sensor", function() - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.hue(50) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.saturation(72) - ) - ) - end, - { test_init = test_init_x_y_color_mode } -) - -test.register_message_test( - "Do not report when receiving a color temperature of 0 mireds", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 0) - } + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.OccupancySensing.attributes.Occupancy } - } -) + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_light_level_motion) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_light_level_motion)) + end + end -test.register_message_test( - "Illuminance reports should generate correct messages", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.IlluminanceMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_device, 2, 21370) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) - } - } -) + test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "init" }) + test.socket.matter:__expect_send({mock_device_light_level_motion.id, subscribe_request}) -test.register_message_test( - "Occupancy reports should generate correct messages", + test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_light_level_motion.id, + clusters.LevelControl.attributes.Options:write(mock_device_light_level_motion, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_light_level_motion:expect_metadata_update({ profile = "light-level-motion" }) + mock_device_light_level_motion:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 1) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 3, 0) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) - } + test_init = test_init_light_level_motion, + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua index 1aaef32d37..eda33fbf5b 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua @@ -59,38 +59,106 @@ local mock_bridge = test.mock_device.build_test_matter_device({ } }) -local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff -} +local mock_basic_bridge = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-bridge.yml"), + matter_version = { hardware = 1, software = 1 }, + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x000E, device_type_revision = 1 } -- Aggregator + } + } + } +}) local function test_init_mock_bridge() - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_bridge) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_bridge)) - end - end - test.socket.matter:__expect_send({mock_bridge.id, subscribe_request}) test.mock_device.add_test_device(mock_bridge) + test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "doConfigure" }) + mock_bridge:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function test_init_mock_basic_bridge() + test.mock_device.add_test_device(mock_basic_bridge) + test.socket.device_lifecycle:__queue_receive({ mock_basic_bridge.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_basic_bridge.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_basic_bridge.id, "doConfigure" }) + mock_basic_bridge:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end test.register_coroutine_test( "Profile should not change for devices with aggregator device type (bridges)", function() - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_bridge) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_bridge)) - end - end - test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "added" }) - test.socket.matter:__expect_send({mock_bridge.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "init" }) - test.socket.matter:__expect_send({mock_bridge.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "doConfigure" }) - mock_bridge:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, - { test_init = test_init_mock_bridge } + { + test_init = test_init_mock_bridge, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Camera reprofiling should happen after software version change when camera endpoint appears", + function() + local updated_endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x000E, device_type_revision = 1 } -- Aggregator + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, + cluster_type = "SERVER" + }, + { cluster_id = clusters.PushAvStreamTransport.ID, cluster_type = "SERVER" } + }, + device_types = { + { device_type_id = 0x0142, device_type_revision = 1 } -- Camera + } + } + } + + test.socket.device_lifecycle:__queue_receive( + mock_basic_bridge:generate_info_changed({ + matter_version = { hardware = 1, software = 2 }, + endpoints = updated_endpoints + }) + ) + + mock_basic_bridge:expect_metadata_update({ + profile = "camera", + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings" + } + } + } + }) + end, + { + test_init = test_init_mock_basic_bridge, + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua index 58ba831074..d49d9f2452 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua @@ -4,16 +4,15 @@ local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" - local clusters = require "st.matter.clusters" local button_attr = capabilities.button.button -local utils = require "st.utils" -local dkjson = require "dkjson" local uint32 = require "st.matter.data_types.Uint32" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" +local st_utils = require "st.utils" ---mock the actual device local mock_device = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("button-battery.yml"), + profile = t_utils.get_profile_definition("button.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, matter_version = {hardware = 1, software = 1}, endpoints = { @@ -33,12 +32,7 @@ local mock_device = test.mock_device.build_test_matter_device({ cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER", - }, - { - cluster_id = clusters.PowerSource.ID, - cluster_type = "SERVER", - feature_map = clusters.PowerSource.types.Feature.BATTERY - }, + } }, device_types = { {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch @@ -47,182 +41,223 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) --- add device for each mock device -local CLUSTER_SUBSCRIBE_LIST ={ - clusters.PowerSource.server.attributes.BatPercentRemaining, - clusters.Switch.server.events.InitialPress, - clusters.Switch.server.events.LongPress, - clusters.Switch.server.events.ShortRelease, - clusters.Switch.server.events.MultiPressComplete, + +local expected_component_to_endpoint_map = { + ["main"] = 1 } +local expected_initial_press_only_state = true + +local function expect_configure_button(device) + test.socket.capability:__expect_send(device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) +end -local function configure_buttons() - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) +local function test_init_for_lifecycle_tests() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) end -local function test_init() +local function test_init_for_message_tests() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) - local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end - end - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() - test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) - configure_buttons() - - local device_info_copy = utils.deep_copy(mock_device.raw_st_data) - device_info_copy.profile.id = "buttons-battery" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) - configure_buttons() - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + mock_device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, expected_component_to_endpoint_map, { persist = true }) + switch_utils.set_field_for_endpoint(mock_device, fields.INITIAL_PRESS_ONLY, 1, expected_initial_press_only_state, { persist = true }) end +test.set_test_init_function(test_init_for_message_tests) + +test.register_coroutine_test( + "Handle initial init lifecycle event", + function () + local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end -test.set_test_init_function(test_init) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.wait_for_events() + assert(mock_device:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as not needing battery support") + assert(mock_device:get_field(fields.profiling_data.POWER_TOPOLOGY) == false, "Device should be marked as not needing to configure power topology") + end, + { + test_init = test_init_for_lifecycle_tests, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle initial doConfigure lifecycle event, where profiling should be skipped", + function () + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, { persist = true }) + mock_device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + expect_configure_button(mock_device) + mock_device:expect_metadata_update({ profile = "button" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + assert(switch_utils.deep_equals(st_utils.deep_copy(mock_device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP)), expected_component_to_endpoint_map), "Component to endpoint map should be set in doConfigure") + assert(switch_utils.get_field_for_endpoint(mock_device, fields.INITIAL_PRESS_ONLY, 1) == expected_initial_press_only_state, "InitialPressOnly should be true") + assert(switch_utils.get_field_for_endpoint(mock_device, fields.SUPPORTS_MULTI_PRESS, 1) == nil, "MultiPress should be nil") + assert(switch_utils.get_field_for_endpoint(mock_device, fields.EMULATE_HELD, 1) == nil, "EmulateHeld should be nil") + end, + { + test_init = test_init_for_lifecycle_tests, + min_api_version = 15 + } +) test.register_message_test( "Handle single press sequence, no hold", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} --move to position 1? - ), + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} --move to position 1 + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + min_api_version = 15 } -} ) test.register_message_test( "Handle single press sequence, with hold", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ), + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) } }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + min_api_version = 15 } -} ) test.register_message_test( "Handle release after short press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 1, {previous_position = 1} + ) + } + }, + { -- this is a double event because the test device in this test shouldn't support the above event + -- but we handle it anyway + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, }, { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.ShortRelease:build_test_event_report( - mock_device, 1, {previous_position = 1} - ) - } - }, - { -- this is a double event because the test device in this test shouldn't support the above event - -- but we handle it anyway - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, + min_api_version = 15 } ) test.register_message_test( "Handle release after long press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 1, {previous_position = 1} + ) + } + }, }, { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongRelease:build_test_event_report( - mock_device, 1, {previous_position = 1} - ) - } - }, + min_api_version = 15 } ) @@ -244,6 +279,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -265,6 +303,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -286,129 +327,275 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) test.register_message_test( "Handle double press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1} - ) - } - }, - { -- again, on a device that reports that it supports double press, this event - -- will not be generated. See a multi-button test file for that case - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 1, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} - ) - } + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) + }, }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) - }, - -} + min_api_version = 15 + } ) test.register_message_test( "Handle multi press for 4 times", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 1, {new_position = 1, total_number_of_presses_counted = 1, previous_position = 0} - ) - } - }, - { -- again, on a device that reports that it supports double press, this event - -- will not be generated. See the multi-button test file for that case - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 1, {new_position = 1, total_number_of_presses_counted = 4, previous_position = 0} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) - }, - -} -) - -test.register_message_test( - "Handle received BatPercentRemaining from device.", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( - mock_device, 1, 150 - ), - }, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 1, previous_position = 0} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See the multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 1, {new_position = 1, total_number_of_presses_counted = 4, previous_position = 0} + ) + } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message( - "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) - ), + message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) }, + }, + { + min_api_version = 15 + } +) + +local mock_device_battery = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("button-battery.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER", + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = clusters.PowerSource.types.Feature.BATTERY + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + } + } +}) + +local function test_init_battery() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_battery) +end + +local function test_init_profile_change_with_battery() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_battery) + -- by this point, init will have run and this will have been set to false + mock_device_battery:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) +end + +test.register_coroutine_test( + "Handle initial init lifecycle event for device with battery", + function () + local CLUSTER_SUBSCRIBE_LIST_BATTERY = { + clusters.PowerSource.server.attributes.AttributeList, + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_BATTERY[1]:subscribe(mock_device_battery) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_BATTERY) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_battery)) end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + assert(mock_device_battery:get_field(fields.profiling_data.BATTERY_SUPPORT) == nil, "Device should not have specified battery support yet") + assert(mock_device_battery:get_field(fields.profiling_data.POWER_TOPOLOGY) == nil, "Device should be marked as not needing to configure power topology") + end, + { + test_init = test_init_battery, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Following init events, after battery has been configured, device should not create subscriptions to AttributeList", + function() + mock_device_battery:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist = true}) + test.wait_for_events() + local CLUSTER_SUBSCRIBE_LIST_BATTERY = { + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_BATTERY[1]:subscribe(mock_device_battery) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_BATTERY) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_battery)) end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle initial doConfigure lifecycle event for device with battery, where profiling should be skipped", + function () + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "PowerSource AttributeList with BatPercentRemaining update should trigger profile update to button-battery", + function() + test.socket.matter:__queue_receive({mock_device_battery.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device_battery, 1, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + expect_configure_button(mock_device_battery) + mock_device_battery:expect_metadata_update({ profile = "button-battery" }) + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "PowerSource AttributeList with BatChargeLevel update should trigger profile update to button-batteryLevel", + function() + test.socket.matter:__queue_receive({mock_device_battery.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device_battery, 1, {uint32(clusters.PowerSource.attributes.BatChargeLevel.ID)} + )}) + expect_configure_button(mock_device_battery) + mock_device_battery:expect_metadata_update({ profile = "button-batteryLevel" }) + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 15 } ) test.register_coroutine_test( - "Test profile change to button-battery when battery percent remaining attribute (attribute ID 12) is available", + "PowerSource AttributeList with no attributes update should set battery support to false and trigger profile update to button", function() test.socket.matter:__queue_receive( { - mock_device.id, - clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(12)}) + mock_device_battery.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device_battery, 1, {uint32(2)}) } ) - mock_device:expect_metadata_update({ profile = "button-battery" }) - end + expect_configure_button(mock_device_battery) + mock_device_battery:expect_metadata_update({ profile = "button" }) + test.wait_for_events() + assert(mock_device_battery:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as not supporting battery") + end, + { + test_init = test_init_profile_change_with_battery, + min_api_version = 15 + } ) test.register_coroutine_test( - "Test profile does not change to button-battery when battery percent remaining attribute (attribute ID 12) is not available", + "BatPercentRemaining attribute event should generate a battery capability report", function() test.socket.matter:__queue_receive( { - mock_device.id, - clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(10)}) + mock_device_battery.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device_battery, 1, 150 + ) } ) - end + test.socket.capability:__expect_send( + mock_device_battery:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end, + { + test_init = test_init_battery, + min_api_version = 15 + } ) --- run the tests test.run_registered_tests() 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 95bef23644..6f5b1251eb 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -1,10 +1,20 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local version = require "version" +local test = require "integration_test" +if version.api < 17 then + -- Run an empty test suite to avoid an error being thrown + test.run_registered_tests() + os.exit(0) +end + local capabilities = require "st.capabilities" +local cluster_base = require "st.matter.cluster_base" local clusters = require "st.matter.clusters" +local camera_fields = require "sub_drivers.camera.camera_utils.fields" local t_utils = require "integration_test.utils" -local test = require "integration_test" +local uint32 = require "st.matter.data_types.Uint32" test.disable_startup_messages() @@ -45,7 +55,8 @@ local mock_device = test.mock_device.build_test_matter_device({ }, { cluster_id = clusters.CameraAvSettingsUserLevelManagement.ID, - feature_map = clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN | + feature_map = clusters.CameraAvSettingsUserLevelManagement.types.Feature.DIGITALPTZ | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN | clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT | clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM | clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS, @@ -58,17 +69,14 @@ local mock_device = test.mock_device.build_test_matter_device({ { cluster_id = clusters.ZoneManagement.ID, feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE | - clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY, + clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY | + clusters.ZoneManagement.types.Feature.USER_DEFINED, cluster_type = "SERVER" }, { cluster_id = clusters.WebRTCTransportProvider.ID, cluster_type = "SERVER" }, - { - cluster_id = clusters.WebRTCTransportRequestor.ID, - cluster_type = "CLIENT" - }, { cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER" @@ -119,9 +127,133 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) +local mock_device_no_per_zone_sensitivity = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.ZoneManagement.ID, + feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE | + clusters.ZoneManagement.types.Feature.USER_DEFINED, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.PushAvStreamTransport.ID, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + } + } +}) + +local mock_device_pan_tilt_only = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvSettingsUserLevelManagement.ID, + feature_map = clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + } + } +}) + +local mock_device_no_user_defined_zone = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.ZoneManagement.ID, + feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.PushAvStreamTransport.ID, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + } + } +}) + local subscribe_request local subscribed_attributes = { clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + clusters.ColorControl.attributes.ColorMode, } local function test_init() @@ -143,6 +275,9 @@ local function test_init() parent_assigned_child_key = string.format("%d", FLOODLIGHT_EP) }) subscribe_request = subscribed_attributes[1]:subscribe(mock_device) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.CameraAVSULMFeatureMapAttr.cluster, camera_fields.CameraAVSULMFeatureMapAttr.ID)) + subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, camera_fields.ZoneManagementFeatureMapAttr.ID)) for i, attr in ipairs(subscribed_attributes) do if i > 1 then subscribe_request:merge(attr:subscribe(mock_device)) end end @@ -153,60 +288,201 @@ end test.set_test_init_function(test_init) -local function update_device_profile() - test.socket.matter:__set_channel_ordering("relaxed") - local uint32 = require "st.matter.data_types.Uint32" - local expected_metadata = { + +local subscribe_request_no_per_zone_sensitivity +local subscribed_attributes_no_per_zone_sensitivity = { + clusters.CameraAvStreamManagement.attributes.AttributeList, +} + +local function test_init_no_per_zone_sensitivity() + test.mock_device.add_test_device(mock_device_no_per_zone_sensitivity) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_per_zone_sensitivity.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_per_zone_sensitivity.id, "init" }) + subscribe_request_no_per_zone_sensitivity = subscribed_attributes_no_per_zone_sensitivity[1]:subscribe(mock_device_no_per_zone_sensitivity) + subscribe_request_no_per_zone_sensitivity:merge(cluster_base.subscribe(mock_device_no_per_zone_sensitivity, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID)) + subscribe_request_no_per_zone_sensitivity:merge(cluster_base.subscribe(mock_device_no_per_zone_sensitivity, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, camera_fields.ZoneManagementFeatureMapAttr.ID)) + for i, attr in ipairs(subscribed_attributes_no_per_zone_sensitivity) do + if i > 1 then subscribe_request_no_per_zone_sensitivity:merge(attr:subscribe(mock_device_no_per_zone_sensitivity)) end + end + test.socket.matter:__expect_send({mock_device_no_per_zone_sensitivity.id, subscribe_request_no_per_zone_sensitivity}) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_per_zone_sensitivity.id, "doConfigure" }) + mock_device_no_per_zone_sensitivity:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local subscribe_request_no_user_defined_zone +local subscribed_attributes_no_user_defined_zone = { + clusters.CameraAvStreamManagement.attributes.AttributeList, +} + +local function test_init_no_user_defined_zone() + test.mock_device.add_test_device(mock_device_no_user_defined_zone) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_user_defined_zone.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_user_defined_zone.id, "init" }) + subscribe_request_no_user_defined_zone = subscribed_attributes_no_user_defined_zone[1]:subscribe(mock_device_no_user_defined_zone) + subscribe_request_no_user_defined_zone:merge(cluster_base.subscribe(mock_device_no_user_defined_zone, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID)) + subscribe_request_no_user_defined_zone:merge(cluster_base.subscribe(mock_device_no_user_defined_zone, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, camera_fields.ZoneManagementFeatureMapAttr.ID)) + for i, attr in ipairs(subscribed_attributes_no_user_defined_zone) do + if i > 1 then subscribe_request_no_user_defined_zone:merge(attr:subscribe(mock_device_no_user_defined_zone)) end + end + test.socket.matter:__expect_send({mock_device_no_user_defined_zone.id, subscribe_request_no_user_defined_zone}) + test.socket.device_lifecycle:__queue_receive({ mock_device_no_user_defined_zone.id, "doConfigure" }) + mock_device_no_user_defined_zone:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +-- mock_device_pan_tilt_only has no CameraAvStreamManagement cluster at all, so do_configure's +-- `if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0` branch calls match_profile +-- directly and synchronously during doConfigure, rather than waiting on an AttributeList report +-- like the other reduced fixtures above. That means the profile/capability metadata update and the +-- "PROVISIONED" transition both happen back-to-back off of the same doConfigure lifecycle event. +local subscribe_request_pan_tilt_only + +local function test_init_pan_tilt_only() + test.mock_device.add_test_device(mock_device_pan_tilt_only) + test.socket.device_lifecycle:__queue_receive({ mock_device_pan_tilt_only.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_pan_tilt_only.id, "init" }) + subscribe_request_pan_tilt_only = cluster_base.subscribe( + mock_device_pan_tilt_only, nil, camera_fields.CameraAVSULMFeatureMapAttr.cluster, camera_fields.CameraAVSULMFeatureMapAttr.ID + ) + test.socket.matter:__expect_send({mock_device_pan_tilt_only.id, subscribe_request_pan_tilt_only}) + test.socket.device_lifecycle:__queue_receive({ mock_device_pan_tilt_only.id, "doConfigure" }) + mock_device_pan_tilt_only:expect_metadata_update({ optional_component_capabilities = { + { "main", { "mechanicalPanTiltZoom" } } + }, + profile = "camera" + }) + mock_device_pan_tilt_only:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local additional_subscribed_attributes = { + clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, + clusters.CameraAvStreamManagement.attributes.ImageRotation, + clusters.CameraAvStreamManagement.attributes.NightVision, + clusters.CameraAvStreamManagement.attributes.NightVisionIllum, + clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal, + clusters.CameraAvStreamManagement.attributes.ImageFlipVertical, + clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn, + clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport, + clusters.CameraAvStreamManagement.attributes.SpeakerMuted, + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled, + clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvStreamManagement.attributes.Viewport, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams, + clusters.Chime.attributes.InstalledChimeSounds, + clusters.Chime.attributes.SelectedChime, + clusters.ZoneManagement.attributes.MaxZones, + clusters.ZoneManagement.attributes.Zones, + clusters.ZoneManagement.attributes.Triggers, + clusters.ZoneManagement.attributes.SensitivityMax, + clusters.ZoneManagement.attributes.Sensitivity, + clusters.ZoneManagement.events.ZoneTriggered, + clusters.ZoneManagement.events.ZoneStopped, + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + clusters.OccupancySensing.attributes.Occupancy, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + 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 = { + { + "main", { - "main", - { - "videoCapture2", - "cameraViewportSettings", - "localMediaStorage", - "audioRecording", - "cameraPrivacyMode", - "imageControl", - "hdr", - "nightVision", - "mechanicalPanTiltZoom", - "videoStreamSettings", - "zoneManagement", - "webrtc", - "motionSensor", - "sounds", - } - }, - { - "statusLed", - { - "switch", - "mode" - } - }, + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings", + "localMediaStorage", + "audioRecording", + "cameraPrivacyMode", + "imageControl", + "hdr", + "nightVision", + "mechanicalPanTiltZoom", + "zoneManagement", + "webrtc", + "motionSensor", + "sounds", + } + }, + { + "statusLed", { - "speaker", - { - "audioMute", - "audioVolume" - } - }, + "switch", + "mode" + } + }, + { + "speaker", { - "microphone", - { - "audioMute", - "audioVolume" - } - }, + "audioMute", + "audioVolume" + } + }, + { + "microphone", { - "doorbell", - { - "button" - } + "audioMute", + "audioVolume" } }, - profile = "camera" - } + { + "doorbell", + { + "button" + } + } + }, + profile = "camera" +} + +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, { @@ -214,11 +490,13 @@ local function update_device_profile() uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) }) }) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) mock_device:expect_metadata_update(expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + test.wait_for_events() local updated_device_profile = t_utils.get_profile_definition( "camera.yml", {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} ) + test.wait_for_events() test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.webrtc.supportedFeatures( @@ -258,1504 +536,3210 @@ local function update_device_profile() {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} )) ) - local additional_subscribed_attributes = { - clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, - clusters.CameraAvStreamManagement.attributes.ImageRotation, - clusters.CameraAvStreamManagement.attributes.NightVision, - clusters.CameraAvStreamManagement.attributes.NightVisionIllum, - clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal, - clusters.CameraAvStreamManagement.attributes.ImageFlipVertical, - clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled, - clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled, - clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn, - clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport, - clusters.CameraAvStreamManagement.attributes.SpeakerMuted, - clusters.CameraAvStreamManagement.attributes.MicrophoneMuted, - clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, - clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, - clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness, - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, - clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, - clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled, - clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled, - clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, - clusters.CameraAvStreamManagement.attributes.VideoSensorParams, - clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, - clusters.CameraAvStreamManagement.attributes.Viewport, - clusters.CameraAvStreamManagement.attributes.MinViewportResolution, - clusters.CameraAvStreamManagement.attributes.AttributeList, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, - clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, - clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, - clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, - clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, - clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, - clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin, - clusters.Chime.attributes.InstalledChimeSounds, - clusters.Chime.attributes.SelectedChime, - clusters.ZoneManagement.attributes.MaxZones, - clusters.ZoneManagement.attributes.Zones, - clusters.ZoneManagement.attributes.Triggers, - clusters.ZoneManagement.attributes.SensitivityMax, - clusters.ZoneManagement.attributes.Sensitivity, - clusters.ZoneManagement.events.ZoneTriggered, - clusters.ZoneManagement.events.ZoneStopped, - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode, - clusters.OccupancySensing.attributes.Occupancy, - clusters.Switch.server.events.InitialPress, - clusters.Switch.server.events.LongPress, - clusters.Switch.server.events.ShortRelease, - clusters.Switch.server.events.MultiPressComplete - } for _, attr in ipairs(additional_subscribed_attributes) do subscribe_request:merge(attr:subscribe(mock_device)) end - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) - test.socket.capability:__expect_send(mock_device:generate_test_message("doorbell", capabilities.button.button.pushed({state_change = false}))) 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 --- Matter Handler UTs +local additional_subscribed_attributes_no_per_zone_sensitivity = { + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.Viewport, + clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.ZoneManagement.attributes.MaxZones, + clusters.ZoneManagement.attributes.Zones, + clusters.ZoneManagement.attributes.Triggers, + clusters.ZoneManagement.attributes.SensitivityMax, + clusters.ZoneManagement.attributes.Sensitivity, + clusters.ZoneManagement.events.ZoneTriggered, + clusters.ZoneManagement.events.ZoneStopped, + clusters.OnOff.attributes.OnOff, +} -test.register_coroutine_test( - "Reports mapping to EnabledState capability data type should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() - local cluster_to_capability_map = { - {cluster = clusters.CameraAvStreamManagement.server.attributes.HDRModeEnabled, capability = capabilities.hdr.hdr}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipHorizontal, capability = capabilities.imageControl.imageFlipHorizontal}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipVertical, capability = capabilities.imageControl.imageFlipVertical}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftRecordingPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softRecordingPrivacyMode}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftLivestreamPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softLivestreamPrivacyMode}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.HardPrivacyModeOn, capability = capabilities.cameraPrivacyMode.hardPrivacyMode}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalSnapshotRecordingEnabled, capability = capabilities.localMediaStorage.localSnapshotRecording}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalVideoRecordingEnabled, capability = capabilities.localMediaStorage.localVideoRecording} +local function update_device_profile_no_per_zone_sensitivity() + local expected_metadata = { + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings", + "zoneManagement", + } + }, + { + "statusLed", + { + "switch", + "mode" + } } - for _, v in ipairs(cluster_to_capability_map) do - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", v.capability("enabled")) - ) - if v.capability == capabilities.imageControl.imageFlipHorizontal then - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal"})) - ) - elseif v.capability == capabilities.imageControl.imageFlipVertical then - 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, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, false) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", v.capability("disabled")) - ) - end + }, + profile = "camera" +} + + test.socket.matter:__queue_receive({ + mock_device_no_per_zone_sensitivity.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device_no_per_zone_sensitivity, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + mock_device_no_per_zone_sensitivity:expect_metadata_update(expected_metadata) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device_no_per_zone_sensitivity:generate_info_changed({ profile = updated_device_profile })) + + test.socket.capability:__expect_send( + mock_device_no_per_zone_sensitivity:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( + {"triggerAugmentation"} + )) + ) + + test.socket.capability:__expect_send( + mock_device_no_per_zone_sensitivity:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports"} + )) + ) + + for _, attr in ipairs(additional_subscribed_attributes_no_per_zone_sensitivity) do + subscribe_request_no_per_zone_sensitivity:merge(attr:subscribe(mock_device_no_per_zone_sensitivity)) end -) + test.socket.matter:__expect_send({mock_device_no_per_zone_sensitivity.id, subscribe_request_no_per_zone_sensitivity}) +end -test.register_coroutine_test( - "Night Vision reports should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() - local cluster_to_capability_map = { - {cluster = clusters.CameraAvStreamManagement.server.attributes.NightVision, capability = capabilities.nightVision.nightVision}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.NightVisionIllum, capability = capabilities.nightVision.illumination} - } - for _, v in ipairs(cluster_to_capability_map) do - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.OFF) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", v.capability("off")) - ) - if v.capability == capabilities.nightVision.illumination then - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.nightVision.supportedAttributes({"illumination"})) - ) - end - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.ON) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", v.capability("on")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.AUTO) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", v.capability("auto")) - ) - end +local additional_subscribed_attributes_no_user_defined_zone = { + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.Viewport, + clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.OnOff.attributes.OnOff, +} + +local function update_device_profile_no_user_defined_zone() + local expected_metadata = { + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings", + } + }, + { + "statusLed", + { + "switch", + "mode" + } + } + }, + profile = "camera" + } + + test.socket.matter:__queue_receive({ + mock_device_no_user_defined_zone.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device_no_user_defined_zone, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + mock_device_no_user_defined_zone:expect_metadata_update(expected_metadata) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device_no_user_defined_zone:generate_info_changed({ profile = updated_device_profile })) + + test.socket.capability:__expect_send( + mock_device_no_user_defined_zone:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports"} + )) + ) + + for _, attr in ipairs(additional_subscribed_attributes_no_user_defined_zone) do + subscribe_request_no_user_defined_zone:merge(attr:subscribe(mock_device_no_user_defined_zone)) end -) + test.socket.matter:__expect_send({mock_device_no_user_defined_zone.id, subscribe_request_no_user_defined_zone}) +end -test.register_coroutine_test( - "Image Rotation reports should generate appropriate events", - function() - local utils = require "st.utils" - update_device_profile() - test.wait_for_events() - local first_value = true - for angle = 0, 400, 50 do - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.ImageRotation:build_test_report_data(mock_device, CAMERA_EP, angle) - }) - local clamped_angle = utils.clamp_value(angle, 0, 359) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.imageControl.imageRotation(clamped_angle)) - ) - if first_value then - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageRotation"})) - ) - first_value = false - end - end +local additional_subscribed_attributes_pan_tilt_only = { + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin, +} + +-- Unlike the other reduced fixtures, mock_device_pan_tilt_only's profile/capability update already +-- happened synchronously during doConfigure (see test_init_pan_tilt_only), so this only needs to +-- simulate the platform pushing the updated profile back down via infoChanged, then confirm the PTZ +-- capability is initialized with pan/tilt/presets only -- no zoom or zoomRange, since this device's +-- CameraAvSettingsUserLevelManagement feature_map never claimed MECHANICAL_ZOOM support. +local function update_device_profile_pan_tilt_only() + local updated_device_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = { + { "main", { "mechanicalPanTiltZoom" } } + }} + ) + test.socket.device_lifecycle:__queue_receive(mock_device_pan_tilt_only:generate_info_changed({ profile = updated_device_profile })) + test.socket.capability:__expect_send( + mock_device_pan_tilt_only:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( + {"pan", "panRange", "tilt", "tiltRange", "presets", "maxPresets"} + )) + ) + for _, attr in ipairs(additional_subscribed_attributes_pan_tilt_only) do + subscribe_request_pan_tilt_only:merge(attr:subscribe(mock_device_pan_tilt_only)) end -) + test.socket.matter:__expect_send({mock_device_pan_tilt_only.id, subscribe_request_pan_tilt_only}) +end + +-- Matter Handler UTs test.register_coroutine_test( - "Two Way Talk Support reports should generate appropriate events", + "Software version change should trigger camera reprofiling when camera endpoint is present", function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( - mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.HALF_DUPLEX - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.webrtc.talkback(true)) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.webrtc.talkbackDuplex("halfDuplex")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( - mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.FULL_DUPLEX - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.webrtc.talkback(true)) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.webrtc.talkbackDuplex("fullDuplex")) + seed_usable_volume_range(mock_device) + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } }) ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( - mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.NOT_SUPPORTED - ) + + mock_device:expect_metadata_update({ + optional_component_capabilities = { + { + "main", + { + "videoCapture2", + "cameraViewportSettings", + "videoStreamSettings", + "localMediaStorage", + "audioRecording", + "cameraPrivacyMode", + "imageControl", + "hdr", + "nightVision", + "mechanicalPanTiltZoom", + "zoneManagement", + "webrtc", + "motionSensor", + "sounds" + } + }, + { + "speaker", + { + "audioMute", + "audioVolume" + } + }, + { + "microphone", + { + "audioMute", + "audioVolume" + } + }, + { + "doorbell", + { + "button" + } + } + }, + profile = "camera" }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.webrtc.talkback(false)) - ) - end + + 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( - "Muted reports should generate appropriate events", + "Software version change should initialize camera capabilities when profile is unchanged", function() - update_device_profile() - test.wait_for_events() - local cluster_to_component_map = { - {cluster = clusters.CameraAvStreamManagement.server.attributes.SpeakerMuted, component = "speaker"}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.MicrophoneMuted, component = "microphone"} + local camera_handler = require "sub_drivers.camera" + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + local button_cfg = require("switch_utils.device_configuration").ButtonCfg + + local match_profile_called = false + local init_called = false + local subscribe_called = false + local configure_buttons_called = false + + local fake_device = { + matter_version = { hardware = 1, software = 3 }, + profile = { id = "camera" }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + } + }, + { + endpoint_id = DOORBELL_EP, + device_types = { + {device_type_id = 0x0143, device_type_revision = 1} -- Doorbell + } + } + }, + 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, } - for _, v in ipairs(cluster_to_component_map) do - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message(v.component, capabilities.audioMute.mute("muted")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, false) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message(v.component, capabilities.audioMute.mute("unmuted")) - ) + + local original_match_profile = camera_cfg.match_profile + local original_init = camera_cfg.initialize_camera_capabilities + local original_configure_buttons = button_cfg.configure_buttons + + camera_cfg.match_profile = function() + match_profile_called = true + return false end - end + camera_cfg.initialize_camera_capabilities = function() init_called = true end + button_cfg.configure_buttons = function() configure_buttons_called = true end + + camera_handler.lifecycle_handlers.infoChanged(nil, fake_device, nil, { + old_st_store = { + matter_version = { hardware = 1, software = 1 }, + profile = fake_device.profile, + } + }) + + camera_cfg.match_profile = original_match_profile + camera_cfg.initialize_camera_capabilities = original_init + button_cfg.configure_buttons = original_configure_buttons + + assert(match_profile_called, "match_profile should be called on software version change") + assert(not init_called, "initialize_camera_capabilities should not be called when capability state is unchanged") + assert(not subscribe_called, "subscribe should not be called when capability state is unchanged") + assert(not configure_buttons_called, "configure_buttons should not be called when capability state is unchanged") + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Volume Level reports should generate appropriate events", + "Camera FeatureMap change should reinitialize capabilities when profile is unchanged", function() - update_device_profile() - test.wait_for_events() - local max_vol = 200 - local min_vol = 0 - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) - }) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) - }) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.MicrophoneMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) - }) + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local reconcile_called = false + local original_reconcile = camera_cfg.reconcile_profile_and_capabilities + + camera_cfg.reconcile_profile_and_capabilities = function(_) + reconcile_called = true + return false + end + test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.MicrophoneMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) + cluster_base.build_test_report_data(mock_device, CAMERA_EP, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID, uint32(0)) }) test.wait_for_events() - local cluster_to_component_map = { - { cluster = clusters.CameraAvStreamManagement.server.attributes.SpeakerVolumeLevel, component = "speaker"}, - { cluster = clusters.CameraAvStreamManagement.server.attributes.MicrophoneVolumeLevel, component = "microphone"} - } - for _, v in ipairs(cluster_to_component_map) do - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, 130) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message(v.component, capabilities.audioVolume.volume(65)) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, 64) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message(v.component, capabilities.audioVolume.volume(32)) - ) - end - end + + camera_cfg.reconcile_profile_and_capabilities = original_reconcile + assert(reconcile_called, "reconcile_profile_and_capabilities should be called") + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Status Light Enabled reports should generate appropriate events", + "Camera privacy mode state compare should ignore table metatable differences", function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.switch.switch.on()) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP, false) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.switch.switch.off()) - ) - end + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local init_event_count = 0 + local original_match_profile = camera_cfg.match_profile + + camera_cfg.match_profile = function() + return false + end + + local fake_device = { + supports_capability = function(_, capability) + return capability == capabilities.cameraPrivacyMode + end, + get_latest_state = function(_, _, _, attribute_name) + if attribute_name == capabilities.cameraPrivacyMode.supportedAttributes.NAME then + return { "softRecordingPrivacyMode", "softLivestreamPrivacyMode" } + elseif attribute_name == capabilities.cameraPrivacyMode.supportedCommands.NAME then + local commands = { "setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode" } + setmetatable(commands, { + __index = function() + return nil + end + }) + return commands + end + return nil + end, + 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 + } + + camera_cfg.reconcile_profile_and_capabilities(fake_device) + camera_cfg.match_profile = original_match_profile + + assert(init_event_count == 0, "cameraPrivacyMode should not be reinitialized for equal values with metatable differences") + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Status Light Brightness reports should generate appropriate events", + "Camera privacy mode should be added to profile when HardPrivacyModeOn is present even without the PRIV feature", function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( - mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.LOW) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.supportedModes( - {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.supportedArguments( - {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.mode("low")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( - mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.MEDIUM) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.mode("medium")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( - mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.HIGH) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.mode("high")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( - mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.AUTO) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.mode("auto")) - ) - end + 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 + } ) -local function receive_rate_distortion_trade_off_points() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints:build_test_report_data( - mock_device, CAMERA_EP, { - clusters.CameraAvStreamManagement.types.RateDistortionTradeOffPointsStruct({ - codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, - resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ - width = 1920, - height = 1080 - }), - min_bit_rate = 5000000 - }), - clusters.CameraAvStreamManagement.types.RateDistortionTradeOffPointsStruct({ - codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.HEVC, - resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ - width = 3840, - height = 2160 - }), - min_bit_rate = 20000000 - }) +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 receive_max_encoded_pixel_rate() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate:build_test_report_data( - mock_device, CAMERA_EP, 124416000) -- 1080p @ 60 fps or 4K @ 15 fps - }) +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 receive_video_sensor_params() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.VideoSensorParams:build_test_report_data( - mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.VideoSensorParamsStruct({ - sensor_width = 7360, - sensor_height = 4912, - max_fps = 60, - max_hdrfps = 30 - }) - ) - }) +local function list_contains(list, value) + for _, v in ipairs(list) do + if v == value then return true end + end + return false end -local function emit_video_sensor_parameters() - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.cameraViewportSettings.videoSensorParameters({ - width = 7360, - height = 4912, - maxFPS = 60 - })) - ) -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" -local function emit_supported_resolutions() - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedResolutions({ - { - width = 1920, - height = 1080, - fps = 60 - }, - { - width = 3840, - height = 2160, - fps = 15 - } - })) - ) -end + -- 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, + }) --- Test receiving RateDistortionTradeOffPoints, MaxEncodedPixelRate, and VideoSensorParams in various orders --- to ensure that cameraViewportSettings and videoStreamSettings capabilities are updated as expected. Note that --- cameraViewportSettings.videoSensorParameters is set in the VideoSensorParams handler and --- videoStreamSettings.supportedResolutions is emitted after all three attributes are received. + camera_cfg.match_profile(fake_device) -test.register_coroutine_test( - "Rate Distortion Trade Off Points, MaxEncodedPixelRate, VideoSensorParams reports should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() - receive_rate_distortion_trade_off_points() - receive_max_encoded_pixel_rate() - receive_video_sensor_params() - emit_video_sensor_parameters() - emit_supported_resolutions() - end + 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( - "Rate Distortion Trade Off Points, VideoSensorParams, MaxEncodedPixelRate reports should generate appropriate events", + "audioVolume should be included on speaker and microphone when the volume range is usable", function() - update_device_profile() - test.wait_for_events() - receive_rate_distortion_trade_off_points() - receive_video_sensor_params() - emit_video_sensor_parameters() - receive_max_encoded_pixel_rate() - emit_supported_resolutions() - end + 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( - "MaxEncodedPixelRate, VideoSensorParams, Rate Distortion Trade Off Points reports should generate appropriate events", + "audioVolume should be preserved on a component whose volume range isn't fully known yet", function() - update_device_profile() - test.wait_for_events() - receive_max_encoded_pixel_rate() - receive_video_sensor_params() - emit_video_sensor_parameters() - receive_rate_distortion_trade_off_points() - emit_supported_resolutions() - end + 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( - "PTZ Position reports should generate appropriate events", + "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.CameraAvSettingsUserLevelManagement.attributes.PanMax:build_test_report_data(mock_device, CAMERA_EP, 150) + 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.CameraAvSettingsUserLevelManagement.attributes.PanMin:build_test_report_data(mock_device, CAMERA_EP, -150) + 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.mechanicalPanTiltZoom.panRange({value = {minimum = -150, maximum = 150}})) + mock_device:generate_test_message("main", capabilities.webrtc.supportedFeatures( + {audio="sendrecv", bundle=true, order="audio/video", supportTrickleICE=true, turnSource="player", video="recvonly"} + )) ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax:build_test_report_data(mock_device, CAMERA_EP, 80) - }) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin:build_test_report_data(mock_device, CAMERA_EP, -80) - }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tiltRange({value = {minimum = -80, maximum = 80}})) + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( + {"pan", "panRange", "tilt", "tiltRange", "zoom", "zoomRange", "presets", "maxPresets"} + )) ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax:build_test_report_data(mock_device, CAMERA_EP, 70) - }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoomRange({value = {minimum = 1, maximum = 70}})) + mock_device:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( + {"triggerAugmentation", "perZoneSensitivity"} + )) ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( - mock_device, CAMERA_EP, {pan = 10, tilt = 20, zoom = 30}) - }) - test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(10)) + mock_device:generate_test_message("main", capabilities.localMediaStorage.supportedAttributes( + {"localVideoRecording"} + )) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(20)) + mock_device:generate_test_message("main", capabilities.audioRecording.audioRecording("enabled")) ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(30)) + mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports", "watermark", "onScreenDisplay"} + )) ) - end + 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( - "PTZ Presets reports should generate appropriate events", + "Camera privacy mode supportedAttributes/supportedCommands should only expose hardPrivacyMode when PRIV feature is absent", function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets:build_test_report_data( - mock_device, CAMERA_EP, {{preset_id = 1, name = "Preset 1", settings = {pan = 10, tilt = 20, zoom = 30}}, - {preset_id = 2, name = "Preset 2", settings = {pan = -55, tilt = 80, zoom = 60}}} - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.presets({ - { id = 1, label = "Preset 1", pan = 10, tilt = 20, zoom = 30}, - { id = 2, label = "Preset 2", pan = -55, tilt = 80, zoom = 60} - })) - ) - end + 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( - "Max Presets reports should generate appropriate events", + "Reports mapping to EnabledState capability data type should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets:build_test_report_data(mock_device, CAMERA_EP, 10) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.maxPresets(10)) - ) - end + local cluster_to_capability_map = { + {cluster = clusters.CameraAvStreamManagement.server.attributes.HDRModeEnabled, capability = capabilities.hdr.hdr}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipHorizontal, capability = capabilities.imageControl.imageFlipHorizontal}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipVertical, capability = capabilities.imageControl.imageFlipVertical}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftRecordingPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softRecordingPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftLivestreamPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softLivestreamPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.HardPrivacyModeOn, capability = capabilities.cameraPrivacyMode.hardPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalSnapshotRecordingEnabled, capability = capabilities.localMediaStorage.localSnapshotRecording}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalVideoRecordingEnabled, capability = capabilities.localMediaStorage.localVideoRecording} + } + for _, v in ipairs(cluster_to_capability_map) do + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("enabled")) + ) + if v.capability == capabilities.imageControl.imageFlipHorizontal then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal"})) + ) + elseif v.capability == capabilities.imageControl.imageFlipVertical then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal", "imageFlipVertical"})) + ) + end + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("disabled")) + ) + end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Max Zones reports should generate appropriate events", + "Night Vision reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.ZoneManagement.attributes.MaxZones:build_test_report_data(mock_device, CAMERA_EP, 10) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.maxZones(10)) - ) - end + local cluster_to_capability_map = { + {cluster = clusters.CameraAvStreamManagement.server.attributes.NightVision, capability = capabilities.nightVision.nightVision}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.NightVisionIllum, capability = capabilities.nightVision.illumination} + } + for _, v in ipairs(cluster_to_capability_map) do + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.OFF) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("off")) + ) + if v.capability == capabilities.nightVision.illumination then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.nightVision.supportedAttributes({"illumination"})) + ) + end + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.ON) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("on")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.AUTO) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("auto")) + ) + end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Zones reports should generate appropriate events", + "Image Rotation reports should generate appropriate events", function() + local utils = require "st.utils" update_device_profile() test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.ZoneManagement.attributes.Zones:build_test_report_data( - mock_device, CAMERA_EP, { - clusters.ZoneManagement.types.ZoneInformationStruct({ - zone_id = 1, - zone_type = clusters.ZoneManagement.types.ZoneTypeEnum.TWODCART_ZONE, - zone_source = clusters.ZoneManagement.types.ZoneSourceEnum.MFG, - two_d_cartesian_zone = clusters.ZoneManagement.types.TwoDCartesianZoneStruct({ - name = "Zone 1", - use = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, - vertices = { - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 0, y = 0 }), - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1920, y = 1080 }) - }, - color = "#FFFFFF" - }) - }) - } + local first_value = true + for angle = 0, 400, 50 do + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.ImageRotation:build_test_report_data(mock_device, CAMERA_EP, angle) + }) + local clamped_angle = utils.clamp_value(angle, 0, 359) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.imageRotation(clamped_angle)) ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.zones({ - { - id = 1, - name = "Zone 1", - type = "2DCartesian", - polygonVertices = { - {vertex = {x = 0, y = 0}}, - {vertex = {x = 1920, y = 1080}} - }, - source = "manufacturer", - use = "motion", - color = "#FFFFFF" - } - })) - ) - end + if first_value then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageRotation"})) + ) + first_value = false + end + end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Triggers reports should generate appropriate events", + "Two Way Talk Support reports should generate appropriate events", function() update_device_profile() test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, - clusters.ZoneManagement.attributes.Triggers:build_test_report_data( - mock_device, CAMERA_EP, { - clusters.ZoneManagement.types.ZoneTriggerControlStruct({ - zone_id = 1, - initial_duration = 8, - augmentation_duration = 4, - max_duration = 20, - blind_duration = 3, - sensitivity = 4 - }) - } + clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.HALF_DUPLEX ) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.triggers({ - { - zoneId = 1, - initialDuration = 8, - augmentationDuration = 4, - maxDuration = 20, - blindDuration = 3, - sensitivity = 4 - } - })) + mock_device:generate_test_message("main", capabilities.webrtc.talkback(true)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.talkbackDuplex("halfDuplex")) ) - end -) - -test.register_coroutine_test( - "Sensitivity reports should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, - clusters.ZoneManagement.attributes.SensitivityMax:build_test_report_data(mock_device, CAMERA_EP, 7) + clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.FULL_DUPLEX + ) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.sensitivityRange({ minimum = 1, maximum = 7}, - {visibility = {displayed = false}})) + mock_device:generate_test_message("main", capabilities.webrtc.talkback(true)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.talkbackDuplex("fullDuplex")) ) test.socket.matter:__queue_receive({ mock_device.id, - clusters.ZoneManagement.attributes.Sensitivity:build_test_report_data(mock_device, CAMERA_EP, 5) + clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TwoWayTalkSupportTypeEnum.NOT_SUPPORTED + ) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.sensitivity(5, {visibility = {displayed = false}})) + mock_device:generate_test_message("main", capabilities.webrtc.talkback(false)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Chime reports should generate appropriate events", + "Muted reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.Chime.attributes.InstalledChimeSounds:build_test_report_data(mock_device, CAMERA_EP, { - clusters.Chime.types.ChimeSoundStruct({chime_id = 1, name = "Sound 1"}), - clusters.Chime.types.ChimeSoundStruct({chime_id = 2, name = "Sound 2"}) - }) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.sounds.supportedSounds({ - {id = 1, label = "Sound 1"}, - {id = 2, label = "Sound 2"}, - }, {visibility = {displayed = false}})) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.Chime.attributes.SelectedChime:build_test_report_data(mock_device, CAMERA_EP, 2) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.sounds.selectedSound(2))) - end + local cluster_to_component_map = { + {cluster = clusters.CameraAvStreamManagement.server.attributes.SpeakerMuted, component = "speaker"}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.MicrophoneMuted, component = "microphone"} + } + for _, v in ipairs(cluster_to_component_map) do + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(v.component, capabilities.audioMute.mute("muted")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(v.component, capabilities.audioMute.mute("unmuted")) + ) + end + end, + { + min_api_version = 14 + } ) --- Event Handler UTs - test.register_coroutine_test( - "Zone events should generate appropriate events", + "Volume Level reports should generate appropriate events", function() update_device_profile() test.wait_for_events() + local max_vol = 200 + local min_vol = 0 test.socket.matter:__queue_receive({ mock_device.id, - clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { - zone = 2, - reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION - }) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}})) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { - zone = 3, - reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION - }) + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}, {zoneId = 3}})) - ) test.socket.matter:__queue_receive({ mock_device.id, - clusters.ZoneManagement.events.ZoneStopped:build_test_event_report(mock_device, CAMERA_EP, { - zone = 2, - reason = clusters.ZoneManagement.types.ZoneEventStoppedReasonEnum.ACTION_STOPPED - }) + clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 3}})) - ) - end -) - -test.register_coroutine_test( - "Button events should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, - clusters.Switch.server.events.InitialPress:build_test_event_report(mock_device, DOORBELL_EP, {new_position = 1}) + clusters.CameraAvStreamManagement.server.attributes.MicrophoneMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) }) test.socket.matter:__queue_receive({ mock_device.id, - clusters.Switch.server.events.MultiPressComplete:build_test_event_report(mock_device, DOORBELL_EP, { - new_position = 1, - total_number_of_presses_counted = 2, - previous_position = 0 - }) + clusters.CameraAvStreamManagement.server.attributes.MicrophoneMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("doorbell", capabilities.button.button.double({state_change = true})) - ) - end -) - --- Capability Handler UTs - -test.register_coroutine_test( - "Set night vision commands should send the appropriate commands", - function() - update_device_profile() test.wait_for_events() - local command_to_attribute_map = { - ["setNightVision"] = clusters.CameraAvStreamManagement.attributes.NightVision, - ["setIllumination"] = clusters.CameraAvStreamManagement.attributes.NightVisionIllum + local cluster_to_component_map = { + { cluster = clusters.CameraAvStreamManagement.server.attributes.SpeakerVolumeLevel, component = "speaker"}, + { cluster = clusters.CameraAvStreamManagement.server.attributes.MicrophoneVolumeLevel, component = "microphone"} } - for cmd, attr in pairs(command_to_attribute_map) do - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "nightVision", component = "main", command = cmd, args = { "off" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.OFF) - }) - test.socket.capability:__queue_receive({ + for _, v in ipairs(cluster_to_component_map) do + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "nightVision", component = "main", command = cmd, args = { "on" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.ON) + v.cluster:build_test_report_data(mock_device, CAMERA_EP, 130) }) - test.socket.capability:__queue_receive({ + test.socket.capability:__expect_send( + mock_device:generate_test_message(v.component, capabilities.audioVolume.volume(65)) + ) + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "nightVision", component = "main", command = cmd, args = { "auto" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.AUTO) + v.cluster:build_test_report_data(mock_device, CAMERA_EP, 64) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(v.component, capabilities.audioVolume.volume(32)) + ) end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Set enabled commands should send the appropriate commands", + "A volume level report racing a zero-width range update should not divide by zero", function() update_device_profile() test.wait_for_events() - local command_to_attribute_map = { - ["setHdr"] = { capability = "hdr", attr = clusters.CameraAvStreamManagement.attributes.HDRModeEnabled}, - ["setImageFlipHorizontal"] = { capability = "imageControl", attr = clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal}, - ["setImageFlipVertical"] = { capability = "imageControl", attr = clusters.CameraAvStreamManagement.attributes.ImageFlipVertical}, - ["setSoftLivestreamPrivacyMode"] = { capability = "cameraPrivacyMode", attr = clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled}, - ["setSoftRecordingPrivacyMode"] = { capability = "cameraPrivacyMode", attr = clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled}, - ["setLocalSnapshotRecording"] = { capability = "localMediaStorage", attr = clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled}, - ["setLocalVideoRecording"] = { capability = "localMediaStorage", attr = clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled} + + -- 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" } - for i, v in pairs(command_to_attribute_map) do - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = v.capability, component = "main", command = i, args = { "enabled" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, v.attr:write(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = v.capability, component = "main", command = i, args = { "disabled" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, v.attr:write(mock_device, CAMERA_EP, false) - }) - end - end + 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( - "Set image rotation command should send the appropriate commands", + "Status Light Enabled reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "imageControl", component = "main", command = "setImageRotation", args = { 10 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.ImageRotation:write(mock_device, CAMERA_EP, 10) + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP, true) }) - test.socket.capability:__queue_receive({ + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.switch.switch.on()) + ) + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "imageControl", component = "main", command = "setImageRotation", args = { 257 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.ImageRotation:write(mock_device, CAMERA_EP, 257) + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP, false) }) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.switch.switch.off()) + ) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Set mute commands should send the appropriate commands", + "Status Light Brightness reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioMute", component = "speaker", command = "setMute", args = { "muted" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__queue_receive({ + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "audioMute", component = "speaker", command = "setMute", args = { "unmuted" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, false) + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.LOW) }) - test.socket.capability:__queue_receive({ + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.supportedModes( + {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.supportedArguments( + {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("low")) + ) + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "audioMute", component = "speaker", command = "mute", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, true) + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.MEDIUM) }) - test.socket.capability:__queue_receive({ + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("medium")) + ) + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "audioMute", component = "speaker", command = "unmute", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, false) + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.HIGH) }) - test.socket.capability:__queue_receive({ + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("high")) + ) + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "audioMute", component = "microphone", command = "setMute", args = { "muted" } }, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.AUTO) }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioMute", component = "microphone", command = "setMute", args = { "unmuted" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, false) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioMute", component = "microphone", command = "mute", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioMute", component = "microphone", command = "unmute", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, false) - }) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("auto")) + ) + end, + { + min_api_version = 14 + } ) +local function receive_rate_distortion_trade_off_points() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.RateDistortionTradeOffPointsStruct({ + codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = 1920, + height = 1080 + }), + min_bit_rate = 5000000 + }), + clusters.CameraAvStreamManagement.types.RateDistortionTradeOffPointsStruct({ + codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.HEVC, + resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = 3840, + height = 2160 + }), + min_bit_rate = 20000000 + }) + } + ) + }) +end + +local function receive_max_encoded_pixel_rate() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate:build_test_report_data( + mock_device, CAMERA_EP, 124416000) -- 1080p @ 60 fps or 4K @ 15 fps + }) +end + +local function receive_min_viewport() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.VideoResolutionStruct({ + width = 1920, + height = 1080 + }) + ) + }) +end + +local function receive_video_sensor_params() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams:build_test_report_data( + mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.VideoSensorParamsStruct({ + sensor_width = 7360, + sensor_height = 4912, + max_fps = 60, + max_hdrfps = 30 + }) + ) + }) +end + +local function emit_min_viewport() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraViewportSettings.minViewportResolution({ + width = 1920, + height = 1080, + })) + ) +end + +local function emit_video_sensor_parameters() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraViewportSettings.videoSensorParameters({ + width = 7360, + height = 4912, + maxFPS = 60 + })) + ) +end + +local function emit_supported_resolutions() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedResolutions({ + { + width = 1920, + height = 1080, + fps = 60 + }, + { + width = 3840, + height = 2160, + fps = 15 + }, + { + width = 7360, + height = 4912, + fps = 0 + } + })) + ) +end + +-- Test receiving RateDistortionTradeOffPoints, MaxEncodedPixelRate, and VideoSensorParams in various orders +-- to ensure that cameraViewportSettings and videoStreamSettings capabilities are updated as expected. Note that +-- cameraViewportSettings.videoSensorParameters is set in the VideoSensorParams handler and +-- videoStreamSettings.supportedResolutions is emitted after all three attributes are received. + test.register_coroutine_test( - "Set Volume command should send the appropriate commands", + "Rate Distortion Trade Off Points, MaxEncodedPixelRate, MinViewport, VideoSensorParams reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + receive_rate_distortion_trade_off_points() + receive_max_encoded_pixel_rate() + receive_min_viewport() + emit_min_viewport() + receive_video_sensor_params() + emit_video_sensor_parameters() + emit_supported_resolutions() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Rate Distortion Trade Off Points, MinViewport, VideoSensorParams, MaxEncodedPixelRate reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + receive_rate_distortion_trade_off_points() + receive_min_viewport() + emit_min_viewport() + receive_video_sensor_params() + emit_video_sensor_parameters() + receive_max_encoded_pixel_rate() + emit_supported_resolutions() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "MaxEncodedPixelRate, MinViewport, VideoSensorParams, Rate Distortion Trade Off Points reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + receive_max_encoded_pixel_rate() + receive_min_viewport() + emit_min_viewport() + receive_video_sensor_params() + emit_video_sensor_parameters() + receive_rate_distortion_trade_off_points() + emit_supported_resolutions() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "PTZ Position reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - local max_vol = 200 - local min_vol = 5 - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) - }) test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax:build_test_report_data(mock_device, CAMERA_EP, 150) }) test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.MicrophoneMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin:build_test_report_data(mock_device, CAMERA_EP, -150) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.panRange({value = {minimum = -150, maximum = 150}})) + ) test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvStreamManagement.server.attributes.MicrophoneMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) - }) - test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioVolume", component = "speaker", command = "setVolume", args = { 0 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 5) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioVolume", component = "speaker", command = "setVolume", args = { 35 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 73) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioVolume", component = "microphone", command = "setVolume", args = { 77 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 155) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioVolume", component = "microphone", command = "setVolume", args = { 100 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 200) + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax:build_test_report_data(mock_device, CAMERA_EP, 80) }) - - ---- test volumeUp command test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 103) + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin:build_test_report_data(mock_device, CAMERA_EP, -80) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(50)) + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tiltRange({value = {minimum = -80, maximum = 80}})) ) - test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioVolume", component = "speaker", command = "volumeUp", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 104) - }) - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 104) + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax:build_test_report_data(mock_device, CAMERA_EP, 70) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(51)) + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoomRange({value = {minimum = 1, maximum = 70}})) ) - - -- test volumeDown command test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 200) + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 10, tilt = 20, zoom = 30}) }) + test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send( - mock_device:generate_test_message("microphone", capabilities.audioVolume.volume(100)) + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(10)) ) - test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "audioVolume", component = "microphone", command = "volumeDown", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 198) - }) - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 198) - }) test.socket.capability:__expect_send( - mock_device:generate_test_message("microphone", capabilities.audioVolume.volume(99)) + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(20)) ) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(30)) + ) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Set Mode command should send the appropriate commands", + "PTZ Position report with zoom omitted should be handled normally on a device with no zoom feature", function() - update_device_profile() + update_device_profile_pan_tilt_only() test.wait_for_events() - local mode_to_enum_map = { - ["low"] = clusters.Global.types.ThreeLevelAutoEnum.LOW, - ["medium"] = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM, - ["high"] = clusters.Global.types.ThreeLevelAutoEnum.HIGH, - ["auto"] = clusters.Global.types.ThreeLevelAutoEnum.AUTO - } - for i, v in pairs(mode_to_enum_map) do - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "mode", component = "speaker", command = "setMode", args = { i } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:write(mock_device, CAMERA_EP, v) - }) - end - end + -- This device has no zoom feature, so its MPTZPosition report never has a zoom field; + -- pan/tilt should still be handled normally. + test.socket.matter:__queue_receive({ + mock_device_pan_tilt_only.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device_pan_tilt_only, CAMERA_EP, {pan = 10, tilt = 20}) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device_pan_tilt_only:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(10)) + ) + test.socket.capability:__expect_send( + mock_device_pan_tilt_only:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(20)) + ) + end, + { + min_api_version = 14, + test_init = test_init_pan_tilt_only + } ) test.register_coroutine_test( - "Set Status LED commands should send the appropriate commands", + "PTZ Presets reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "switch", component = "statusLed", command = "on", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__queue_receive({ + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "switch", component = "statusLed", command = "off", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP, false) + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets:build_test_report_data( + mock_device, CAMERA_EP, {{preset_id = 1, name = "Preset 1", settings = {pan = 10, tilt = 20, zoom = 30}}, + {preset_id = 2, name = "Preset 2", settings = {pan = -55, tilt = 80, zoom = 60}}} + ) }) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.presets({ + { id = 1, label = "Preset 1", pan = 10, tilt = 20, zoom = 30}, + { id = 2, label = "Preset 2", pan = -55, tilt = 80, zoom = 60} + })) + ) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Set Relative PTZ commands should send the appropriate commands", + "Max Presets reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "panRelative", args = { 10 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 10, 0, 0) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "tiltRelative", args = { -35 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 0, -35, 0) - }) - test.socket.capability:__queue_receive({ + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "zoomRelative", args = { 80 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 0, 0, 80) + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets:build_test_report_data(mock_device, CAMERA_EP, 10) }) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.maxPresets(10)) + ) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Set PTZ commands should send the appropriate commands", + "Max Zones reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "setPanTiltZoom", args = { 10, 20, 30 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 10, 20, 30) - }) test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( - mock_device, CAMERA_EP, {pan = 10, tilt = 20, zoom = 30}) + clusters.ZoneManagement.attributes.MaxZones:build_test_report_data(mock_device, CAMERA_EP, 10) }) - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(10)) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(20)) - ) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(30)) + mock_device:generate_test_message("main", capabilities.zoneManagement.maxZones(10)) ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Zones reports should generate appropriate events", + function() + update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "setPan", args = { 50 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, 20, 30) - }) test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( - mock_device, CAMERA_EP, {pan = 50, tilt = 20, zoom = 30}) + clusters.ZoneManagement.attributes.Zones:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.ZoneManagement.types.ZoneInformationStruct({ + zone_id = 1, + zone_type = clusters.ZoneManagement.types.ZoneTypeEnum.TWODCART_ZONE, + zone_source = clusters.ZoneManagement.types.ZoneSourceEnum.MFG, + two_d_cartesian_zone = clusters.ZoneManagement.types.TwoDCartesianZoneStruct({ + name = "Zone 1", + use = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 0, y = 0 }), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1920, y = 1080 }) + }, + color = "#FFFFFF" + }) + }) + } + ) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(50)) + mock_device:generate_test_message("main", capabilities.zoneManagement.zones({ + { + id = 1, + name = "Zone 1", + type = "2DCartesian", + polygonVertices = { + {vertex = {x = 0, y = 0}}, + {vertex = {x = 1920, y = 1080}} + }, + source = "manufacturer", + use = "motion", + color = "#FFFFFF" + } + })) ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Triggers reports should generate appropriate events", + function() + update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "setTilt", args = { -44 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, -44, 30) - }) test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( - mock_device, CAMERA_EP, {pan = 50, tilt = -44, zoom = 30}) + clusters.ZoneManagement.attributes.Triggers:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.ZoneManagement.types.ZoneTriggerControlStruct({ + zone_id = 1, + initial_duration = 8, + augmentation_duration = 4, + max_duration = 20, + blind_duration = 3, + sensitivity = 4 + }) + } + ) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(-44)) + mock_device:generate_test_message("main", capabilities.zoneManagement.triggers({ + { + zoneId = 1, + initialDuration = 8, + augmentationDuration = 4, + maxDuration = 20, + blindDuration = 3, + sensitivity = 4 + } + })) ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Sensitivity reports should generate appropriate events", + function() + update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "setZoom", args = { 5 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, -44, 5) + clusters.ZoneManagement.attributes.SensitivityMax:build_test_report_data(mock_device, CAMERA_EP, 7) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.sensitivityRange({ minimum = 1, maximum = 7}, + {visibility = {displayed = false}})) + ) test.socket.matter:__queue_receive({ mock_device.id, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( - mock_device, CAMERA_EP, {pan = 50, tilt = -44, zoom = 5}) + clusters.ZoneManagement.attributes.Sensitivity:build_test_report_data(mock_device, CAMERA_EP, 5) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(5)) + mock_device:generate_test_message("main", capabilities.zoneManagement.sensitivity(5, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Preset commands should send the appropriate commands", + "Chime reports should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "savePreset", args = { 1, "Preset 1" } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSavePreset(mock_device, CAMERA_EP, 1, "Preset 1") - }) - test.socket.capability:__queue_receive({ + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "removePreset", args = { 1 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRemovePreset(mock_device, CAMERA_EP, 1) + clusters.Chime.attributes.InstalledChimeSounds:build_test_report_data(mock_device, CAMERA_EP, { + clusters.Chime.types.ChimeSoundStruct({chime_id = 1, name = "Sound 1"}), + clusters.Chime.types.ChimeSoundStruct({chime_id = 2, name = "Sound 2"}) + }) }) - test.socket.capability:__queue_receive({ + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.sounds.supportedSounds({ + {id = 1, label = "Sound 1"}, + {id = 2, label = "Sound 2"}, + }, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "mechanicalPanTiltZoom", component = "main", command = "moveToPreset", args = { 2 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZMoveToPreset(mock_device, CAMERA_EP, 2) + clusters.Chime.attributes.SelectedChime:build_test_report_data(mock_device, CAMERA_EP, 2) }) - end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.sounds.selectedSound(2))) + end, + { + min_api_version = 14 + } ) +-- Event Handler UTs + test.register_coroutine_test( - "Sound commands should send the appropriate commands", + "Zone events should generate appropriate events", function() update_device_profile() test.wait_for_events() - test.socket.capability:__queue_receive({ + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "sounds", component = "main", command = "setSelectedSound", args = { 1 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.Chime.attributes.SelectedChime:write(mock_device, CAMERA_EP, 1) + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "sounds", component = "main", command = "playSound", args = {} }, + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 3, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.Chime.server.commands.PlayChimeSound(mock_device, CAMERA_EP) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}, {zoneId = 3}})) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneStopped:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventStoppedReasonEnum.ACTION_STOPPED + }) }) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 3}})) + ) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Zone Management zone commands should send the appropriate commands", + "Duplicate ZoneTriggered events should not duplicate triggeredZones state", function() update_device_profile() test.wait_for_events() - local use_map = { - ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, - ["focus"] = clusters.ZoneManagement.types.ZoneUseEnum.FOCUS, - ["privacy"] = clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({{zoneId = 2}})) + ) + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventTriggeredReasonEnum.MOTION + }) + }) + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.events.ZoneStopped:build_test_event_report(mock_device, CAMERA_EP, { + zone = 2, + reason = clusters.ZoneManagement.types.ZoneEventStoppedReasonEnum.ACTION_STOPPED + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggeredZones({})) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Button events should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.server.events.InitialPress:build_test_event_report(mock_device, DOORBELL_EP, {new_position = 1}) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.server.events.MultiPressComplete:build_test_event_report(mock_device, DOORBELL_EP, { + new_position = 1, + total_number_of_presses_counted = 2, + previous_position = 0 + }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("doorbell", capabilities.button.button.double({state_change = true})) + ) + end, + { + min_api_version = 14 + } +) + +-- Capability Handler UTs + +test.register_coroutine_test( + "Set night vision commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local command_to_attribute_map = { + ["setNightVision"] = clusters.CameraAvStreamManagement.attributes.NightVision, + ["setIllumination"] = clusters.CameraAvStreamManagement.attributes.NightVisionIllum } - for i, v in pairs(use_map) do + for cmd, attr in pairs(command_to_attribute_map) do test.socket.capability:__queue_receive({ mock_device.id, - { capability = "zoneManagement", component = "main", command = "newZone", args = { - i .. " zone", {{value = {x = 0, y = 0}}, {value = {x = 1920, y = 1080}} }, i, "blue" - }} + { capability = "nightVision", component = "main", command = cmd, args = { "off" } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone(mock_device, CAMERA_EP, - clusters.ZoneManagement.types.TwoDCartesianZoneStruct( - { - name = i .. " zone", - use = v, - vertices = { - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 0, y = 0}), - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 1920, y = 1080}) - }, - color = "blue" - } - ) - ) + mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.OFF) }) - end - local zone_id = 1 - for i, v in pairs(use_map) do test.socket.capability:__queue_receive({ mock_device.id, - { capability = "zoneManagement", component = "main", command = "updateZone", args = { - zone_id, "updated " .. i .. " zone", {{value = {x = 50, y = 50}}, {value = {x = 1000, y = 1000}} }, i, "red" - }} + { capability = "nightVision", component = "main", command = cmd, args = { "on" } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.UpdateTwoDCartesianZone(mock_device, CAMERA_EP, - zone_id, - clusters.ZoneManagement.types.TwoDCartesianZoneStruct( - { - name = "updated " .. i .. " zone", - use = v, - vertices = { - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 50, y = 50 }), - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1000, y = 1000 }) - }, - color = "red" - } - ) - ) + mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.ON) }) - zone_id = zone_id + 1 - end - for i = 1, 3 do test.socket.capability:__queue_receive({ mock_device.id, - { capability = "zoneManagement", component = "main", command = "removeZone", args = { i } } + { capability = "nightVision", component = "main", command = cmd, args = { "auto" } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.RemoveZone(mock_device, CAMERA_EP, i) + mock_device.id, attr:write(mock_device, CAMERA_EP, clusters.CameraAvStreamManagement.types.TriStateAutoEnum.AUTO) }) end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Zone Management zone commands should send the appropriate commands - missing optional color argument", + "Set enabled commands should send the appropriate commands", function() update_device_profile() test.wait_for_events() - local use_map = { - ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, - ["focus"] = clusters.ZoneManagement.types.ZoneUseEnum.FOCUS, - ["privacy"] = clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY + local command_to_attribute_map = { + ["setHdr"] = { capability = "hdr", attr = clusters.CameraAvStreamManagement.attributes.HDRModeEnabled}, + ["setImageFlipHorizontal"] = { capability = "imageControl", attr = clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal}, + ["setImageFlipVertical"] = { capability = "imageControl", attr = clusters.CameraAvStreamManagement.attributes.ImageFlipVertical}, + ["setSoftLivestreamPrivacyMode"] = { capability = "cameraPrivacyMode", attr = clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled}, + ["setSoftRecordingPrivacyMode"] = { capability = "cameraPrivacyMode", attr = clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled}, + ["setLocalSnapshotRecording"] = { capability = "localMediaStorage", attr = clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled}, + ["setLocalVideoRecording"] = { capability = "localMediaStorage", attr = clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled} } - for i, v in pairs(use_map) do - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "zoneManagement", component = "main", command = "newZone", args = { - i .. " zone", {{value = {x = 0, y = 0}}, {value = {x = 1920, y = 1080}} }, i - }} - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone(mock_device, CAMERA_EP, - clusters.ZoneManagement.types.TwoDCartesianZoneStruct( - { - name = i .. " zone", - use = v, - vertices = { - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 0, y = 0}), - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 1920, y = 1080}) - }, - } - ) - ) - }) - end - local zone_id = 1 - for i, v in pairs(use_map) do + for i, v in pairs(command_to_attribute_map) do test.socket.capability:__queue_receive({ mock_device.id, - { capability = "zoneManagement", component = "main", command = "updateZone", args = { - zone_id, "updated " .. i .. " zone", {{value = {x = 50, y = 50}}, {value = {x = 1000, y = 1000}} }, i, "red" - }} + { capability = v.capability, component = "main", command = i, args = { "enabled" } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.UpdateTwoDCartesianZone(mock_device, CAMERA_EP, - zone_id, - clusters.ZoneManagement.types.TwoDCartesianZoneStruct( - { - name = "updated " .. i .. " zone", - use = v, - vertices = { - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 50, y = 50 }), - clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1000, y = 1000 }) - }, - color = "red" - } - ) - ) + mock_device.id, v.attr:write(mock_device, CAMERA_EP, true) }) - zone_id = zone_id + 1 - end - for i = 1, 3 do test.socket.capability:__queue_receive({ mock_device.id, - { capability = "zoneManagement", component = "main", command = "removeZone", args = { i } } + { capability = v.capability, component = "main", command = i, args = { "disabled" } }, }) test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.RemoveZone(mock_device, CAMERA_EP, i) + mock_device.id, v.attr:write(mock_device, CAMERA_EP, false) }) end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Set image rotation command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "imageControl", component = "main", command = "setImageRotation", args = { 10 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.ImageRotation:write(mock_device, CAMERA_EP, 10) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "imageControl", component = "main", command = "setImageRotation", args = { 257 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.ImageRotation:write(mock_device, CAMERA_EP, 257) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Set mute commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "speaker", command = "setMute", args = { "muted" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "speaker", command = "setMute", args = { "unmuted" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "speaker", command = "mute", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "speaker", command = "unmute", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerMuted:write(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "microphone", command = "setMute", args = { "muted" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "microphone", command = "setMute", args = { "unmuted" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, false) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "microphone", command = "mute", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioMute", component = "microphone", command = "unmute", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneMuted:write(mock_device, CAMERA_EP, false) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Set Volume command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local max_vol = 200 + local min_vol = 5 + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.MicrophoneMaxLevel:build_test_report_data(mock_device, CAMERA_EP, max_vol) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.MicrophoneMinLevel:build_test_report_data(mock_device, CAMERA_EP, min_vol) + }) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "speaker", command = "setVolume", args = { 0 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 5) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "speaker", command = "setVolume", args = { 35 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 73) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "microphone", command = "setVolume", args = { 77 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 155) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "microphone", command = "setVolume", args = { 100 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 200) + }) + + ---- test volumeUp command + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 103) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(50)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "speaker", command = "volumeUp", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:write(mock_device, CAMERA_EP, 104) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 104) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(51)) + ) + + -- test volumeDown command + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("microphone", capabilities.audioVolume.volume(100)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "audioVolume", component = "microphone", command = "volumeDown", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:write(mock_device, CAMERA_EP, 198) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 198) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("microphone", capabilities.audioVolume.volume(99)) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Set Mode command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local mode_to_enum_map = { + ["low"] = clusters.Global.types.ThreeLevelAutoEnum.LOW, + ["medium"] = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM, + ["high"] = clusters.Global.types.ThreeLevelAutoEnum.HIGH, + ["auto"] = clusters.Global.types.ThreeLevelAutoEnum.AUTO + } + for i, v in pairs(mode_to_enum_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "speaker", command = "setMode", args = { i } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:write(mock_device, CAMERA_EP, v) + }) + end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Set Status LED commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "statusLed", command = "on", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "statusLed", command = "off", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP, false) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Set Relative PTZ commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "panRelative", args = { 10 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, 10, nil, nil) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "tiltRelative", args = { -35 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, nil, -35, nil) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "zoomRelative", args = { 80 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRelativeMove(mock_device, CAMERA_EP, nil, nil, 80) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Set PTZ commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setPanTiltZoom", args = { 10, 20, 30 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 10, 20, 30) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 10, tilt = 20, zoom = 30}) + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(10)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(20)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(30)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setPan", args = { 50 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, 50, nil, nil) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 50, tilt = 20, zoom = 30}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.pan(50)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setTilt", args = { -44 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, nil, -44, nil) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 50, tilt = -44, zoom = 30}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.tilt(-44)) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "setZoom", args = { 5 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSetPosition(mock_device, CAMERA_EP, nil, nil, 5) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition:build_test_report_data( + mock_device, CAMERA_EP, {pan = 50, tilt = -44, zoom = 5}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.zoom(5)) + ) + end, + { + min_api_version = 14 + } +) + +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() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "savePreset", args = { 1, "Preset 1" } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZSavePreset(mock_device, CAMERA_EP, 1, "Preset 1") + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "removePreset", args = { 1 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZRemovePreset(mock_device, CAMERA_EP, 1) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mechanicalPanTiltZoom", component = "main", command = "moveToPreset", args = { 2 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZMoveToPreset(mock_device, CAMERA_EP, 2) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Sound commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "sounds", component = "main", command = "setSelectedSound", args = { 1 } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.Chime.attributes.SelectedChime:write(mock_device, CAMERA_EP, 1) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "sounds", component = "main", command = "playSound", args = {} }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.Chime.server.commands.PlayChimeSound(mock_device, CAMERA_EP) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Zone Management zone commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + local use_map = { + ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + ["focus"] = clusters.ZoneManagement.types.ZoneUseEnum.FOCUS, + ["privacy"] = clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY + } + for i, v in pairs(use_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "newZone", args = { + i .. " zone", {{value = {x = 0, y = 0}}, {value = {x = 1920, y = 1080}} }, i, "#FFFFFF" + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone(mock_device, CAMERA_EP, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = i .. " zone", + use = v, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 0, y = 0}), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 1920, y = 1080}) + }, + color = "#FFFFFF" + } + ) + ) + }) + end + local zone_id = 1 + for i, v in pairs(use_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "updateZone", args = { + zone_id, "updated " .. i .. " zone", {{value = {x = 50, y = 50}}, {value = {x = 1000, y = 1000}} }, i, "red" + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.UpdateTwoDCartesianZone(mock_device, CAMERA_EP, + zone_id, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = "updated " .. i .. " zone", + use = v, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 50, y = 50 }), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1000, y = 1000 }) + }, + color = "red" + } + ) + ) + }) + zone_id = zone_id + 1 + end + for i = 1, 3 do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "removeZone", args = { i } } + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveZone(mock_device, CAMERA_EP, i) + }) + end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Zone Management zone commands should send the appropriate commands - missing optional color argument", + function() + update_device_profile() + test.wait_for_events() + local use_map = { + ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + ["focus"] = clusters.ZoneManagement.types.ZoneUseEnum.FOCUS, + ["privacy"] = clusters.ZoneManagement.types.ZoneUseEnum.PRIVACY + } + for i, v in pairs(use_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "newZone", args = { + i .. " zone", {{value = {x = 0, y = 0}}, {value = {x = 1920, y = 1080}} }, i + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone(mock_device, CAMERA_EP, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = i .. " zone", + use = v, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 0, y = 0}), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 1920, y = 1080}) + }, + } + ) + ) + }) + end + local zone_id = 1 + for i, v in pairs(use_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "updateZone", args = { + zone_id, "updated " .. i .. " zone", {{value = {x = 50, y = 50}}, {value = {x = 1000, y = 1000}} }, i, "red" + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.UpdateTwoDCartesianZone(mock_device, CAMERA_EP, + zone_id, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct( + { + name = "updated " .. i .. " zone", + use = v, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 50, y = 50 }), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({ x = 1000, y = 1000 }) + }, + color = "red" + } + ) + ) + }) + zone_id = zone_id + 1 + end + for i = 1, 3 do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "removeZone", args = { i } } + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveZone(mock_device, CAMERA_EP, i) + }) + end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Zone Management trigger commands should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + + -- Create the trigger + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { + 1, 10, 3, 15, 3, 5 + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger(mock_device, CAMERA_EP, { + zone_id = 1, + initial_duration = 10, + augmentation_duration = 3, + max_duration = 15, + blind_duration = 3, + sensitivity = 5 + }) + }) + + -- The device reports the Triggers attribute with the newly created trigger and the capability is updated + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Triggers:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.ZoneManagement.types.ZoneTriggerControlStruct({ + zone_id = 1, initial_duration = 10, augmentation_duration = 3, max_duration = 15, blind_duration = 3, sensitivity = 5 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggers({{ + zoneId = 1, initialDuration = 10, augmentationDuration = 3, maxDuration = 15, blindDuration = 3, sensitivity = 5 + }})) + ) + test.wait_for_events() + + -- Update trigger, note that some arguments are optional. In this case, + -- blindDuration is not specified in the capability command. + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { + 1, 8, 7, 25, 3, 1 + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger(mock_device, CAMERA_EP, { + zone_id = 1, + initial_duration = 8, + augmentation_duration = 7, + max_duration = 25, + blind_duration = 3, + sensitivity = 1 + }) + }) + + -- Remove the trigger + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "removeTrigger", args = { 1 } } + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveTrigger(mock_device, CAMERA_EP, 1) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Removing a zone with an existing trigger should send RemoveTrigger followed by RemoveZone", + function() + update_device_profile() + test.wait_for_events() + + -- Create a zone + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "newZone", args = { + "motion zone", {{value = {x = 0, y = 0}}, {value = {x = 1920, y = 1080}}}, "motion", "#FFFFFF" + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateTwoDCartesianZone(mock_device, CAMERA_EP, + clusters.ZoneManagement.types.TwoDCartesianZoneStruct({ + name = "motion zone", + use = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, + vertices = { + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 0, y = 0}), + clusters.ZoneManagement.types.TwoDCartesianVertexStruct({x = 1920, y = 1080}) + }, + color = "#FFFFFF" + }) + ) + }) + + -- Create a trigger + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { + 1, 10, 3, 15, 3, 5 + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger(mock_device, CAMERA_EP, { + zone_id = 1, + initial_duration = 10, + augmentation_duration = 3, + max_duration = 15, + blind_duration = 3, + sensitivity = 5 + }) + }) + + -- Receive the Triggers attribute update from the device reflecting the new trigger + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Triggers:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.ZoneManagement.types.ZoneTriggerControlStruct({ + zone_id = 1, initial_duration = 10, augmentation_duration = 3, + max_duration = 15, blind_duration = 3, sensitivity = 5 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.triggers({{ + zoneId = 1, initialDuration = 10, augmentationDuration = 3, + maxDuration = 15, blindDuration = 3, sensitivity = 5 + }})) + ) + test.wait_for_events() + + -- Receive removeZone command: since a trigger exists for zone 1, RemoveTrigger is sent first, then RemoveZone + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "zoneManagement", component = "main", command = "removeZone", args = { 1 } } + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveTrigger(mock_device, CAMERA_EP, 1) + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.ZoneManagement.server.commands.RemoveZone(mock_device, CAMERA_EP, 1) + }) + test.wait_for_events() + + -- Receive the updated Zones attribute from the device with the zone removed + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ZoneManagement.attributes.Zones:build_test_report_data(mock_device, CAMERA_EP, {}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.zones({value = {}})) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "setStream with label and viewport changes should emit capability event", + function() + update_device_profile() + test.wait_for_events() + -- Set up an existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 3, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 1920, + height = 1080, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1920, y = 1080 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change label and viewport only + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 3, + "liveStream", -- type + "My Stream", -- label + { width = 1920, height = 1080, fps = 30 }, -- resolution + { upperLeftVertex = {x = 100, y = 100}, lowerRightVertex = {x = 1820, y = 980} }, -- viewport + "disabled", -- watermark + "disabled" -- onScreenDisplay + }} + }) + -- Should send DPTZSetViewport command + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.DPTZSetViewport(mock_device, CAMERA_EP, + 3, + clusters.Global.types.ViewportStruct({ + x1 = 100, + x2 = 1820, + y1 = 100, + y2 = 980 + }) + ) + }) + -- Should emit updated capability directly, no stream reallocation + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "My Stream", + type = "liveStream", + resolution = { + width = 1920, + height = 1080, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 100, y = 100 }, + lowerRightVertex = { x = 1820, y = 980 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "setStream with only watermark/OSD changes should use VideoStreamModify", + function() + update_device_profile() + test.wait_for_events() + -- Set up an existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 1, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 360}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 360}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = true, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 640, + height = 360, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 640, y = 360 } + }, + watermark = "enabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change watermark and OSD only + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 1, + "liveStream", -- type + "Stream 1", -- label + { width = 640, height = 360, fps = 30 }, -- resolution + { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 640, y = 360} }, -- viewport + "disabled", -- watermark + "enabled" -- onScreenDisplay + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.server.commands.VideoStreamModify(mock_device, CAMERA_EP, + 1, false, true + ) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "setStream with only label change should emit capability event", + function() + update_device_profile() + test.wait_for_events() + -- Set up existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 2, + stream_usage = clusters.Global.types.StreamUsageEnum.RECORDING, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 15, + max_frame_rate = 30, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 2, + data = { + label = "Stream 1", + type = "clipRecording", + resolution = { + width = 1280, + height = 720, + fps = 15 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1280, y = 720 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change label only + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 2, + "clipRecording", -- type + "Recording Stream", -- label + { width = 1280, height = 720, fps = 15 }, -- resolution + { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 1280, y = 720} }, -- viewport + "disabled", -- watermark + "disabled" -- onScreenDisplay + }} + }) + -- Should emit updated capability directly, no stream reallocation + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 2, + data = { + label = "Recording Stream", + type = "clipRecording", + resolution = { + width = 1280, + height = 720, + fps = 15 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1280, y = 720 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) + end +) + +test.register_coroutine_test( + "setStream with only viewport change should send DPTZSetViewport command", + function() + update_device_profile() + test.wait_for_events() + -- Set up existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 5, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 3840, height = 2160}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 3840, height = 2160}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = true, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 5, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 3840, + height = 2160, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 3840, y = 2160 } + }, + watermark = "disabled", + onScreenDisplay = "enabled" + } + } + })) + ) + test.wait_for_events() + -- Change only viewport + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 5, + "liveStream", -- type + "Stream 1", -- label + { width = 3840, height = 2160, fps = 30 }, -- resolution + { upperLeftVertex = {x = 500, y = 500}, lowerRightVertex = {x = 3340, y = 1660} }, -- viewport + "disabled", -- watermark + "enabled" -- onScreenDisplay + }} + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvSettingsUserLevelManagement.server.commands.DPTZSetViewport(mock_device, CAMERA_EP, + 5, + clusters.Global.types.ViewportStruct({ + x1 = 500, + x2 = 3340, + y1 = 500, + y2 = 1660 + }) + ) + }) + -- Should emit updated capability directly, no stream reallocation + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 5, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 3840, + height = 2160, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 500, y = 500 }, + lowerRightVertex = { x = 3340, y = 1660 } + }, + watermark = "disabled", + onScreenDisplay = "enabled" + } + } + })) + ) + end +) + +test.register_coroutine_test( + "setStream with resolution change should trigger reallocation", + function() + update_device_profile() + test.wait_for_events() + -- Set up existing stream + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 1, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = true, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { + width = 1280, + height = 720, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1280, y = 720 } + }, + watermark = "enabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.wait_for_events() + -- Change resolution and reallocate stream + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 1, + "liveStream", -- type + "HD Stream", -- label + { width = 1920, height = 1080, fps = 30 }, -- resolution + { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 1280, y = 720} }, -- viewport + "enabled", -- watermark + "disabled" -- onScreenDisplay + }} + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "HD Stream", + type = "liveStream", + resolution = { + width = 1280, + height = 720, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1280, y = 720 } + }, + watermark = "enabled", + onScreenDisplay = "disabled" + } + } + })) + ) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.server.commands.VideoStreamDeallocate(mock_device, CAMERA_EP, 1) + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.server.commands.VideoStreamAllocate(mock_device, CAMERA_EP, + clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + 30, + 60, + clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + 10000, + 2000000, + 4000, + true, + false + ) + }) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 1, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "HD Stream", + type = "liveStream", + resolution = { + width = 1920, + height = 1080, + fps = 30 + }, + viewport = { + upperLeftVertex = { x = 0, y = 0 }, + lowerRightVertex = { x = 1920, y = 1080 } + }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) end ) test.register_coroutine_test( - "Zone Management trigger commands should send the appropriate commands", + "Stream label should persist across attribute reports", function() update_device_profile() test.wait_for_events() - - -- Create the trigger - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { - 1, 10, 3, 15, 3, 5 - }} - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger(mock_device, CAMERA_EP, { - zone_id = 1, - initial_duration = 10, - augmentation_duration = 3, - max_duration = 15, - blind_duration = 3, - sensitivity = 5 - }) - }) - - -- The device reports the Triggers attribute with the newly created trigger and the capability is updated + -- Set up existing stream test.socket.matter:__queue_receive({ mock_device.id, - clusters.ZoneManagement.attributes.Triggers:build_test_report_data( + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( mock_device, CAMERA_EP, { - clusters.ZoneManagement.types.ZoneTriggerControlStruct({ - zone_id = 1, initial_duration = 10, augmentation_duration = 3, max_duration = 15, blind_duration = 3, sensitivity = 5 + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 3, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 480}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 480}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 }) } ) }) test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.triggers({{ - zoneId = 1, initialDuration = 10, augmentationDuration = 3, maxDuration = 15, blindDuration = 3, sensitivity = 5 - }})) + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { width = 640, height = 480, fps = 30 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 640, y = 480 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) ) test.wait_for_events() - - -- Update trigger, note that some arguments are optional. In this case, - -- blindDuration is not specified in the capability command. - + -- Change label test.socket.capability:__queue_receive({ mock_device.id, - { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { - 1, 8, 7, 25, 3, 1 + { + capability = "videoStreamSettings", component = "main", command = "setStream", args = { + 3, + "liveStream", -- type + "My Camera", -- label + { width = 640, height = 480, fps = 30 }, -- resolution + { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 640, y = 480} }, -- viewport + "disabled", -- watermark + "disabled" -- onScreenDisplay }} }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.CreateOrUpdateTrigger(mock_device, CAMERA_EP, { - zone_id = 1, - initial_duration = 8, - augmentation_duration = 7, - max_duration = 25, - blind_duration = 3, - sensitivity = 1 - }) - }) - - -- Remove the trigger - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "zoneManagement", component = "main", command = "removeTrigger", args = { 1 } } - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.ZoneManagement.server.commands.RemoveTrigger(mock_device, CAMERA_EP, 1) - }) - end -) - -test.register_coroutine_test( - "Stream management commands should send the appropriate commands", - function() - update_device_profile() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "My Camera", + type = "liveStream", + resolution = { width = 640, height = 480, fps = 30 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 640, y = 480 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) test.wait_for_events() - test.socket.capability:__queue_receive({ + -- Simulate another AllocatedVideoStreams report + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "videoStreamSettings", component = "main", command = "setStream", args = { - 3, - "liveStream", - "Stream 3", - { width = 1920, height = 1080, fps = 30 }, - { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 1920, y = 1080} }, - "enabled", - "disabled" - }} - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.server.commands.VideoStreamModify(mock_device, CAMERA_EP, - 3, true, false + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 3, + stream_usage = clusters.Global.types.StreamUsageEnum.LIVE_VIEW, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 30, + max_frame_rate = 60, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 480}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 480}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }) + } ) }) + -- Should preserve the custom label from capability state + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 3, + data = { + label = "My Camera", + type = "liveStream", + resolution = { width = 640, height = 480, fps = 30 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 640, y = 480 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) end ) test.register_coroutine_test( - "Stream management setStream command should modify an existing stream", + "DPTZStreams attribute should update viewports in capability", function() update_device_profile() test.wait_for_events() + -- Set up multiple existing streams test.socket.matter:__queue_receive({ mock_device.id, clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams:build_test_report_data( @@ -1766,12 +3750,27 @@ test.register_coroutine_test( video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, min_frame_rate = 30, max_frame_rate = 60, - min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 360}), - max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 640, height = 360}), + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1920, height = 1080}), min_bit_rate = 10000, max_bit_rate = 10000, key_frame_interval = 4000, - watermark_enabled = true, + watermark_enabled = false, + osd_enabled = false, + reference_count = 0 + }), + clusters.CameraAvStreamManagement.types.VideoStreamStruct({ + video_stream_id = 2, + stream_usage = clusters.Global.types.StreamUsageEnum.RECORDING, + video_codec = clusters.CameraAvStreamManagement.types.VideoCodecEnum.H264, + min_frame_rate = 15, + max_frame_rate = 30, + min_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + max_resolution = clusters.CameraAvStreamManagement.types.VideoResolutionStruct({width = 1280, height = 720}), + min_bit_rate = 10000, + max_bit_rate = 10000, + key_frame_interval = 4000, + watermark_enabled = false, osd_enabled = false, reference_count = 0 }) @@ -1785,36 +3784,234 @@ test.register_coroutine_test( data = { label = "Stream 1", type = "liveStream", - resolution = { - width = 640, - height = 360, - fps = 30 - }, - watermark = "enabled", + resolution = { width = 1920, height = 1080, fps = 30 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 1920, y = 1080 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + }, + { + streamId = 2, + data = { + label = "Stream 2", + type = "clipRecording", + resolution = { width = 1280, height = 720, fps = 15 }, + viewport = { upperLeftVertex = { x = 0, y = 0 }, lowerRightVertex = { x = 1280, y = 720 } }, + watermark = "disabled", onScreenDisplay = "disabled" } } })) ) - test.socket.capability:__queue_receive({ + test.socket.matter:__queue_receive({ mock_device.id, - { capability = "videoStreamSettings", component = "main", command = "setStream", args = { - 1, - "liveStream", - "Stream 1", - { width = 640, height = 360, fps = 30 }, - { upperLeftVertex = {x = 0, y = 0}, lowerRightVertex = {x = 640, y = 360} }, - "disabled", - "enabled" - }} - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.server.commands.VideoStreamModify(mock_device, CAMERA_EP, - 1, false, true + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams:build_test_report_data( + mock_device, CAMERA_EP, { + clusters.CameraAvSettingsUserLevelManagement.types.DPTZStruct({ + video_stream_id = 1, + viewport = clusters.Global.types.ViewportStruct({ + x1 = 200, + x2 = 1720, + y1 = 100, + y2 = 980 + }) + }), + clusters.CameraAvSettingsUserLevelManagement.types.DPTZStruct({ + video_stream_id = 2, + viewport = clusters.Global.types.ViewportStruct({ + x1 = 50, + x2 = 1230, + y1 = 50, + y2 = 670 + }) + }) + } ) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.videoStreams({ + { + streamId = 1, + data = { + label = "Stream 1", + type = "liveStream", + resolution = { width = 1920, height = 1080, fps = 30 }, + viewport = { upperLeftVertex = { x = 200, y = 100 }, lowerRightVertex = { x = 1720, y = 980 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + }, + { + streamId = 2, + data = { + label = "Stream 2", + type = "clipRecording", + resolution = { width = 1280, height = 720, fps = 15 }, + viewport = { upperLeftVertex = { x = 50, y = 50 }, lowerRightVertex = { x = 1230, y = 670 } }, + watermark = "disabled", + onScreenDisplay = "disabled" + } + } + })) + ) end ) +test.register_coroutine_test( + "Camera profile should not update for an unchanged Status Light AttributeList report", + function() + update_device_profile() + test.wait_for_events() + + local camera_cfg = require("sub_drivers.camera.camera_utils.device_configuration") + local original_reconcile = camera_cfg.reconcile_profile_and_capabilities + camera_cfg.reconcile_profile_and_capabilities = function(...) return false end + + 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) + }) + }) + test.wait_for_events() + + camera_cfg.reconcile_profile_and_capabilities = original_reconcile + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Camera profile should update for a changed Status Light AttributeList report", + 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) + }) + }) + local updated_expected_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds", } + }, + { "statusLed", + { "switch" } -- only switch capability remains + }, + { "speaker", + { "audioMute", "audioVolume" } + }, + { "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( + "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() + update_device_profile_no_per_zone_sensitivity() + test.wait_for_events() + end, + { + test_init = test_init_no_per_zone_sensitivity, + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Camera profile should not include zoneManagement when USER_DEFINED feature is missing", + function() + update_device_profile_no_user_defined_zone() + test.wait_for_events() + end, + { + test_init = test_init_no_user_defined_zone, + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Zone Management trigger command should be ignored when zoneManagement capability is not exposed", + function() + update_device_profile_no_user_defined_zone() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device_no_user_defined_zone.id, + { capability = "zoneManagement", component = "main", command = "createOrUpdateTrigger", args = { + 1, 10, 3, 15, 3, 5 + }} + }) + test.wait_for_events() + end, + { + test_init = test_init_no_user_defined_zone, + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Zone Management setSensitivity command should be ignored when zoneManagement capability is not exposed", + function() + update_device_profile_no_user_defined_zone() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device_no_user_defined_zone.id, + { capability = "zoneManagement", component = "main", command = "setSensitivity", args = { 5 } } + }) + test.wait_for_events() + end, + { + test_init = test_init_no_user_defined_zone, + min_api_version = 14 + } +) + -- run the tests test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua new file mode 100644 index 0000000000..31795f91ea --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_irrigation_system.lua @@ -0,0 +1,483 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local version = require "version" +local test = require "integration_test" +if version.api < 17 then + test.run_registered_tests() + os.exit(0) +end + + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local version = require "version" + +if version.api < 11 then + clusters.ValveConfigurationAndControl = require "embedded_clusters.ValveConfigurationAndControl" +end + +local endpoints = { + ROOT_EP = 0, + IRRIGATION_SYSTEM_EP = 1, + VALVE_1_EP = 2, + VALVE_2_EP = 3, + VALVE_3_EP = 4 +} + +-- Mock device representing an irrigation system with 3 valve endpoints +local mock_irrigation_system = test.mock_device.build_test_matter_device({ + label = "Matter Irrigation System", + profile = t_utils.get_profile_definition("irrigation-system.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = endpoints.ROOT_EP, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = endpoints.IRRIGATION_SYSTEM_EP, + clusters = { + {cluster_id = clusters.Descriptor.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.FlowMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.OperationalState.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0040, device_type_revision = 1} -- Irrigation System + } + }, + { + endpoint_id = endpoints.VALVE_1_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + }, + { + endpoint_id = endpoints.VALVE_2_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + }, + { + endpoint_id = endpoints.VALVE_3_EP, + clusters = { + { + cluster_id = clusters.ValveConfigurationAndControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 2 -- LEVEL feature + }, + }, + device_types = { + {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve + } + } + } +}) + +local mock_children = {} +for _, endpoint in ipairs(mock_irrigation_system.endpoints) do + if endpoint.endpoint_id == 3 or endpoint.endpoint_id == 4 then + local child_data = { + profile = t_utils.get_profile_definition("water-valve-level.yml"), + device_network_id = string.format("%s:%d", mock_irrigation_system.id, endpoint.endpoint_id), + parent_device_id = mock_irrigation_system.id, + parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) + } + mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) + end +end + +local subscribe_request + +local expected_metadata = { + optional_component_capabilities = { { "main", { "level", "flowMeasurement", "operationalState", } } }, + profile = "irrigation-system" +} + +local function test_init() + test.mock_device.add_test_device(mock_irrigation_system) + local cluster_subscribe_list = { + clusters.ValveConfigurationAndControl.attributes.CurrentState, + clusters.ValveConfigurationAndControl.attributes.CurrentLevel, + } + subscribe_request = cluster_subscribe_list[1]:subscribe(mock_irrigation_system) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_irrigation_system)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "init" }) + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_irrigation_system.id, "doConfigure" }) + mock_irrigation_system:expect_metadata_update(expected_metadata) + mock_irrigation_system:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end + for i = 3,4 do + mock_irrigation_system:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("Matter Irrigation System %d", i - 1), + profile = "irrigation-system", + parent_device_id = mock_irrigation_system.id, + parent_assigned_child_key = string.format("%d", i) + }) + end + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) +end +test.set_test_init_function(test_init) + + +local additional_subscribed_attributes = { + clusters.FlowMeasurement.attributes.MeasuredValue, + clusters.FlowMeasurement.attributes.MaxMeasuredValue, + clusters.FlowMeasurement.attributes.MinMeasuredValue, + clusters.OperationalState.attributes.AcceptedCommandList, + clusters.OperationalState.attributes.OperationalError, + clusters.OperationalState.attributes.OperationalState, +} + +local function update_device_profile() + local updated_device_profile = t_utils.get_profile_definition( + "irrigation-system.yml", { enabled_optional_capabilities = expected_metadata.optional_component_capabilities } + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_irrigation_system:generate_info_changed({ profile = updated_device_profile })) + for _, attr in ipairs(additional_subscribed_attributes) do + subscribe_request:merge(attr:subscribe(mock_irrigation_system)) + end + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) +end + +test.register_coroutine_test( + "Parent device: Open command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "valve", component = "main", command = "open", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP) + }) + end +) + +test.register_coroutine_test( + "Parent device: Close command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "valve", component = "main", command = "close", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_1_EP) + }) + end +) + +test.register_coroutine_test( + "Parent device: Set level command should send the appropriate commands", + function() + update_device_profile() + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "level", component = "main", command = "setLevel", args = { 75 } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_1_EP, nil, 75) + }) + end +) + +test.register_coroutine_test( + "Parent device: Current state closed should generate closed event", + function() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_1_EP, + 0 + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.valve.valve.closed()) + ) + end +) + +test.register_coroutine_test( + "Parent device: Current level reports should generate appropriate events", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_1_EP, + 60 + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.level.level(60)) + ) + end +) + +test.register_coroutine_test( + "Flow reports should generate correct messages", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.server.attributes.MeasuredValue:build_test_report_data(mock_irrigation_system, 1, 20 * 10) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message( + "main", + capabilities.flowMeasurement.flow({ value = 20.0, unit = "m^3/h" }) + ) + ) + end +) + +test.register_coroutine_test( + "Min and max flow attributes set capability constraint", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.attributes.MinMeasuredValue:build_test_report_data(mock_irrigation_system, 1, 20) + }) + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.FlowMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_irrigation_system, 1, 5000) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message( + "main", + capabilities.flowMeasurement.flowRange({ + value = { minimum = 2.0, maximum = 500.0 }, + unit = "m^3/h" + }) + ) + ) + end +) + +test.register_coroutine_test( + "Child device valve 2: Open command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_2_EP].id, + { capability = "valve", component = "main", command = "open", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP) + }) + end +) + +test.register_coroutine_test( + "Child device valve 2: Set level command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_2_EP].id, + { capability = "level", component = "main", command = "setLevel", args = { 40 } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Open(mock_irrigation_system, endpoints.VALVE_2_EP, nil, 40) + }) + end +) + +test.register_coroutine_test( + "Child device valve 2: Current state closed should generate closed event", + function() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentState:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_2_EP, + 0 + ) + }) + test.socket.capability:__expect_send( + mock_children[endpoints.VALVE_2_EP]:generate_test_message("main", capabilities.valve.valve.closed()) + ) + end +) + +test.register_coroutine_test( + "Child device valve 3: Close command should send the appropriate commands", + function() + test.socket.capability:__queue_receive({ + mock_children[endpoints.VALVE_3_EP].id, + { capability = "valve", component = "main", command = "close", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.commands.Close(mock_irrigation_system, endpoints.VALVE_3_EP) + }) + end +) + +test.register_coroutine_test( + "Child device valve 3: Current level reports should generate appropriate events", + function() + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.ValveConfigurationAndControl.server.attributes.CurrentLevel:build_test_report_data( + mock_irrigation_system, + endpoints.VALVE_3_EP, + 100 + ) + }) + test.socket.capability:__expect_send( + mock_children[endpoints.VALVE_3_EP]:generate_test_message("main", capabilities.level.level(100)) + ) + end +) + +test.register_coroutine_test( + "OperationalState attribute running should generate running event", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalState:build_test_report_data( + mock_irrigation_system, + endpoints.IRRIGATION_SYSTEM_EP, + clusters.OperationalState.types.OperationalStateEnum.RUNNING + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.operationalState.operationalState.running()) + ) + end +) + +test.register_coroutine_test( + "OperationalState OperationalError UNABLE_TO_COMPLETE_OPERATION should generate unableToCompleteOperation event", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalError:build_test_report_data( + mock_irrigation_system, + endpoints.IRRIGATION_SYSTEM_EP, { error_state_id = clusters.OperationalState.types.ErrorStateEnum.UNABLE_TO_COMPLETE_OPERATION } + ) + }) + test.socket.capability:__expect_send( + mock_irrigation_system:generate_test_message("main", capabilities.operationalState.operationalState.unableToCompleteOperation()) + ) + end +) + +test.register_coroutine_test( + "OperationalState resume command should send Resume and re-read state/error to irrigation system EP", + function() + update_device_profile() + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "operationalState", component = "main", command = "resume", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.server.commands.Resume(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalState:read(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalError:read(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + end +) + +test.register_coroutine_test( + "OperationalState pause command should send Pause and re-read state/error to irrigation system EP", + function() + update_device_profile() + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_irrigation_system.id, + { capability = "operationalState", component = "main", command = "pause", args = { } } + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.server.commands.Pause(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalState:read(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + test.socket.matter:__expect_send({ + mock_irrigation_system.id, + clusters.OperationalState.attributes.OperationalError:read(mock_irrigation_system, endpoints.IRRIGATION_SYSTEM_EP) + }) + end +) + +test.register_coroutine_test( + "Child device doConfigure: update child profile based on its endpoint configuration", + function() + test.socket.device_lifecycle:__queue_receive({ mock_children[endpoints.VALVE_2_EP].id, "doConfigure" }) + mock_children[endpoints.VALVE_2_EP]:expect_metadata_update({ + profile = "irrigation-system", + optional_component_capabilities = {{"main", {"level"}}} + }) + mock_children[endpoints.VALVE_2_EP]:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.matter:__expect_send({mock_irrigation_system.id, subscribe_request}) + end +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua index 676a376726..d3be60f352 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua @@ -5,18 +5,14 @@ local capabilities = require "st.capabilities" local clusters = require "st.matter.generated.zap_clusters" local t_utils = require "integration_test.utils" local test = require "integration_test" -local version = require "version" - -local TRANSITION_TIME = 0 -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 local mock_device_ep1 = 1 local mock_device_ep2 = 2 local mock_device = test.mock_device.build_test_matter_device({ label = "Matter Fan Light", - profile = t_utils.get_profile_definition("fan-modular.yml", {}), + profile = t_utils.get_profile_definition("fan-modular.yml", + {enabled_optional_capabilities = {{"main", {"fanSpeedPercent", "fanMode"}}}}), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -58,6 +54,40 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) +local mock_device_capabilities_disabled = test.mock_device.build_test_matter_device({ + label = "Matter Fan Light", + profile = t_utils.get_profile_definition("fan-modular.yml", + {enabled_optional_capabilities = {{"main", {}}}}), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + matter_version = { + software = 1, + hardware = 1, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = mock_device_ep2, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 15}, + }, + device_types = { + {device_type_id = 0x002B, device_type_revision = 1,} -- Fan + } + } + } +}) + local CLUSTER_SUBSCRIBE_LIST ={ clusters.OnOff.attributes.OnOff, clusters.LevelControl.attributes.CurrentLevel, @@ -92,9 +122,6 @@ local function test_init() if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end end - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) -- since all fan capabilities are optional, nothing is initially subscribed to - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -110,98 +137,40 @@ local function test_init() }) mock_device:expect_metadata_update({ profile = "fan-modular", optional_component_capabilities = {{"main", {"fanSpeedPercent", "fanMode"}}} }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - local updated_device_profile = t_utils.get_profile_definition("fan-modular.yml", - {enabled_optional_capabilities = {{"main", {"fanSpeedPercent", "fanMode"}}}} - ) - test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) end test.set_test_init_function(test_init) test.register_coroutine_test( - "Switch capability should send the appropriate commands", function() - test.socket.capability:__queue_receive( - { - mock_child.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - ) - if version.api >= 11 then - test.socket.devices:__expect_send( - { - "register_native_capability_cmd_handler", - { device_uuid = mock_child.id, capability_id = "switch", capability_cmd_id = "on" } - } - ) + "Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", function() + local cluster_subscribe_list ={ + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_capabilities_disabled) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_capabilities_disabled)) end end - test.socket.matter:__expect_send( - { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, mock_device_ep1) - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, mock_device_ep1, true) - } - ) - test.socket.devices:__expect_send( - { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } - } + test.socket.device_lifecycle:__queue_receive(mock_device_capabilities_disabled:generate_info_changed( + {profile = {id = "00000000-1111-2222-3333-000000000004", components = { main = {capabilities={["fanSpeedPercent"] = {id="fanSpeedPercent", version=1}, ["fanMode"] = {id="fanMode", version=1}, ["firmwareUpdate"] = {id="firmwareUpdate", version=1}, ["refresh"] = {id="refresh", version=1}}}}}}) ) - test.socket.capability:__expect_send( - mock_child:generate_test_message( - "main", capabilities.switch.switch.on() - ) - ) - end + test.socket.matter:__expect_send({mock_device_capabilities_disabled.id, subscribe_request}) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_capabilities_disabled) end, + min_api_version = 15 + } ) -test.register_message_test( - "Set color temperature should send the appropriate commands", +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_capabilities_disabled:generate_info_changed({})) + end, { - { - channel = "capability", - direction = "receive", - message = { - mock_child.id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, mock_device_ep1, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, mock_device_ep1) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, mock_device_ep1, 556) - } - }, - { - channel = "capability", - direction = "send", - message = mock_child:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, + test_init = function() test.mock_device.add_test_device(mock_device_capabilities_disabled) end, + min_api_version = 15 } ) @@ -248,6 +217,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("medium")) } + }, + { + min_api_version = 15 } ) @@ -281,6 +253,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.fanMode.supportedFanModes({"off", "low", "medium", "high", "auto"}, {visibility={displayed=false}})) }, + }, + { + min_api_version = 15 } ) @@ -303,6 +278,9 @@ test.register_message_test( clusters.FanControl.attributes.FanMode:write(mock_device, mock_device_ep2, FanMode.LOW) } } + }, + { + min_api_version = 15 } ) @@ -325,6 +303,9 @@ test.register_message_test( clusters.FanControl.attributes.PercentSetting:write(mock_device, mock_device_ep2, 64) } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua index 301fb36cf0..0f4c1e28b1 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua @@ -6,16 +6,15 @@ local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local utils = require "st.utils" local dkjson = require "dkjson" - local clusters = require "st.matter.generated.zap_clusters" local button_attr = capabilities.button.button +local uint32 = require "st.matter.data_types.Uint32" --- Mock a 5-button device using endpoints non-consecutive endpoints local mock_device = test.mock_device.build_test_matter_device( { - profile = t_utils.get_profile_definition("5-button-battery.yml"), + profile = t_utils.get_profile_definition("5-button.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, - matter_version = {hardware = 1, sofrware = 1}, + matter_version = {hardware = 1, software = 1}, endpoints = { { endpoint_id = 0, @@ -32,7 +31,6 @@ local mock_device = test.mock_device.build_test_matter_device( feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, cluster_type = "SERVER" }, - {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY} }, device_types = { {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch @@ -95,115 +93,273 @@ local mock_device = test.mock_device.build_test_matter_device( }, }) --- add device for each mock device -local CLUSTER_SUBSCRIBE_LIST ={ - clusters.PowerSource.server.attributes.BatPercentRemaining, - clusters.Switch.server.events.InitialPress, - clusters.Switch.server.events.LongPress, - clusters.Switch.server.events.ShortRelease, - clusters.Switch.server.events.MultiPressComplete, -} - -local function expect_configure_buttons() - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) - - test.socket.capability:__expect_send(mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) - - test.socket.capability:__expect_send(mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) - - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 50)}) - test.socket.capability:__expect_send(mock_device:generate_test_message("button4", button_attr.pushed({state_change = false}))) +local mock_device_battery = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("5-button-battery.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 0, + clusters = {}, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1}, -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + }, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 20, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_RELEASE, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 30, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 50, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 60, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + }, + }) + +local function expect_configure_buttons(device) + test.socket.capability:__expect_send(device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) + test.socket.matter:__expect_send({device.id, clusters.Switch.attributes.MultiPressMax:read(device, 50)}) + test.socket.matter:__expect_send({device.id, clusters.Switch.attributes.MultiPressMax:read(device, 60)}) +end - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 60)}) - test.socket.capability:__expect_send(mock_device:generate_test_message("button5", button_attr.pushed({state_change = false}))) +local function update_profile() + test.socket.matter:__queue_receive({mock_device_battery.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device_battery, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + expect_configure_buttons(mock_device_battery) + mock_device_battery:expect_metadata_update({ profile = "5-button-battery" }) end -- All messages queued and expectations set are done before the driver is actually run local function test_init() - -- we dont want the integration test framework to generate init/doConfigure, we are doing that here - -- so we can set the proper expectations on those events. - test.disable_startup_messages() - test.mock_device.add_test_device(mock_device) -- make sure the cache is populated + local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } - -- added sets a bunch of fields on the device, and calls init local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) 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, "added" }) + + -- we dont want the integration test framework to generate init/doConfigure, we are doing that here + -- so we can set the proper expectations on those events. + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) -- make sure the cache is populated -- init results in subscription interaction test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) --doConfigure sets the provisioning state to provisioned - test.socket.matter:__expect_send({mock_device.id, clusters.PowerSource.attributes.AttributeList:read()}) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - expect_configure_buttons() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + expect_configure_buttons(mock_device) + mock_device:expect_metadata_update({ profile = "5-button" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end - -- simulate the profile change update taking affect and the device info changing - local device_info_copy = utils.deep_copy(mock_device.raw_st_data) - device_info_copy.profile.id = "5-buttons-battery" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - expect_configure_buttons() +local function test_init_battery() + local CLUSTER_SUBSCRIBE_LIST = { + clusters.PowerSource.server.attributes.AttributeList, + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device_battery) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_battery)) end + end + + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_battery) + + test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end + test.set_test_init_function(test_init) +test.register_coroutine_test( + "Simulate the profile change update taking affect and the device info changing", + function() + test.socket.matter:__set_channel_ordering("relaxed") + update_profile() + test.wait_for_events() + local device_info_copy = utils.deep_copy(mock_device_battery.raw_st_data) + device_info_copy.profile.id = "5-buttons-battery" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "infoChanged", device_info_json }) + -- due to the AttributeList being processed in update_profile, setting profiling_data.BATTERY_SUPPORT, + -- subsequent subscriptions will not include AttributeList. + local UPDATED_CLUSTER_SUBSCRIBE_LIST = { + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + local updated_subscribe_request = UPDATED_CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device_battery) + for i, clus in ipairs(UPDATED_CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then updated_subscribe_request:merge(clus:subscribe(mock_device_battery)) end + end + test.socket.matter:__expect_send({mock_device_battery.id, updated_subscribe_request}) + expect_configure_buttons(mock_device_battery) + end, + { + test_init = test_init_battery, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle received BatPercentRemaining from device.", + function() + update_profile() + test.socket.matter:__queue_receive( + { + mock_device_battery.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device_battery, 10, 150 + ) + } + ) + test.socket.capability:__expect_send( + mock_device_battery:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end, + { + test_init = test_init_battery, + min_api_version = 15 + } +) + test.register_message_test( "Handle single press sequence, no hold", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} --move to position 1? - ), + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} --move to position 1 + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + min_api_version = 15 } -} ) test.register_message_test( "Handle single press sequence for short release-supported button", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 20, {new_position = 1} --move to position 1? - ), - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.ShortRelease:build_test_event_report( - mock_device, 20, {previous_position = 0} --move to position 1? - ), + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 20, {new_position = 1} --move to position 1 + ), + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 20, {previous_position = 0} --move to position 1 + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) --should send initial press } }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) --should send initial press + min_api_version = 15 } -} ) test.register_coroutine_test( @@ -225,7 +381,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.held({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -247,7 +406,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -268,7 +430,10 @@ test.register_coroutine_test( mock_device, 50, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -305,7 +470,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button5", button_attr.double({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -330,7 +498,10 @@ test.register_coroutine_test( mock_device, 30, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -355,122 +526,134 @@ test.register_coroutine_test( mock_device, 60, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_message_test( "Handle single press sequence, with hold", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ), + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) } }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + min_api_version = 15 } -} ) test.register_message_test( "Handle release after short press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.ShortRelease:build_test_event_report( + mock_device, 10, {previous_position = 1} + ) + } + }, + { -- this is a double event because the test device in this test shouldn't support the above event + -- but we handle it anyway + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, }, { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.ShortRelease:build_test_event_report( - mock_device, 10, {previous_position = 1} - ) - } - }, - { -- this is a double event because the test device in this test shouldn't support the above event - -- but we handle it anyway - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, + min_api_version = 15 } ) test.register_message_test( "Handle release after long press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ), - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ), + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongRelease:build_test_event_report( + mock_device, 10, {previous_position = 1} + ) + } + }, }, { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongRelease:build_test_event_report( - mock_device, 10, {previous_position = 1} - ) - } - }, + min_api_version = 15 } ) @@ -492,6 +675,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -513,6 +699,9 @@ test.register_message_test( message = mock_device:generate_test_message("button5", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -534,166 +723,156 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) test.register_message_test( "Handle double press", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ) - } - }, - { -- again, on a device that reports that it supports double press, this event - -- will not be generated. See a multi-button test file for that case - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 10, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} - ) - } + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 10, {new_position = 1, total_number_of_presses_counted = 2, previous_position = 0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) + }, }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) - }, - -} + min_api_version = 15 + } ) test.register_message_test( "Handle multi press for 4 times", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 10, {new_position = 1} - ) - } - }, - { -- again, on a device that reports that it supports double press, this event - -- will not be generated. See a multi-button test file for that case - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 10, {new_position = 1, total_number_of_presses_counted = 4, previous_position=0} - ) - } + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 10, {new_position = 1} + ) + } + }, + { -- again, on a device that reports that it supports double press, this event + -- will not be generated. See a multi-button test file for that case + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 10, {new_position = 1, total_number_of_presses_counted = 4, previous_position=0} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) + }, }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) - }, - -} -) - -test.register_message_test( - "Receiving a max press attribute of 2 should emit correct event", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.attributes.MultiPressMax:build_test_report_data( - mock_device, 50, 2 - ) - }, - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button4", - capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) - }, - } + min_api_version = 15 + } ) test.register_message_test( - "Handle received BatPercentRemaining from device.", { + "Receiving a max press attribute of 2 should emit correct event", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( - mock_device, 10, 150 - ), + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_device, 50, 2 + ) }, }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message( - "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) - ), + message = mock_device:generate_test_message("button4", + capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) - test.register_message_test( "Handle a long press including MultiPressComplete", { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 60, {new_position = 1} - ) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 60, {new_position = 1} - ) + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.LongPress:build_test_event_report( + mock_device, 60, {new_position = 1} + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("button5", button_attr.held({state_change = true})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Switch.events.MultiPressComplete:build_test_event_report( + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} + ) + } } + -- no double event }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("button5", button_attr.held({state_change = true})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} - ) - } + min_api_version = 15 } - -- no double event -} ) test.register_message_test( @@ -704,7 +883,7 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 60, {new_position = 1} + mock_device, 60, {new_position = 1} ) } }, @@ -714,7 +893,7 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.events.LongPress:build_test_event_report( - mock_device, 60, {new_position = 1} + mock_device, 60, {new_position = 1} ) } }, @@ -729,7 +908,7 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.events.InitialPress:build_test_event_report( - mock_device, 60, {new_position = 1} + mock_device, 60, {new_position = 1} ) } }, @@ -739,7 +918,7 @@ test.register_message_test( message = { mock_device.id, clusters.Switch.events.MultiPressComplete:build_test_event_report( - mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} + mock_device, 60, {new_position = 0, total_number_of_presses_counted = 1, previous_position=0} ) } }, @@ -748,7 +927,74 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button5", button_attr.pushed({state_change = true})) } + }, + { + min_api_version = 15 } ) --- run the tests + +local function reset_battery_profiling_info() + local fields = require "switch_utils.fields" + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) +end + +test.register_coroutine_test( + "Test profile does not change to button-battery when battery percent remaining attribute (attribute ID 12) is not available", + function() + reset_battery_profiling_info() + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(10)}) + } + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Test profile change to button-batteryLevel when battery percent remaining attribute (attribute ID 14) is available", + function() + reset_battery_profiling_info() + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32( + clusters.PowerSource.attributes.BatChargeLevel.ID + )}) + } + ) + expect_configure_buttons(mock_device) + mock_device:expect_metadata_update({ profile = "5-button-batteryLevel" }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Test profile change to button-battery when battery percent remaining attribute (attribute ID 12) is available", + function() + reset_battery_profiling_info() + test.wait_for_events() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32( + clusters.PowerSource.attributes.BatPercentRemaining.ID + )}) + } + ) + expect_configure_buttons(mock_device) + mock_device:expect_metadata_update({ profile = "5-button-battery" }) + end, + { + min_api_version = 15 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua index dca5f20645..ec2e7ae289 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua @@ -125,22 +125,11 @@ local CLUSTER_SUBSCRIBE_LIST ={ local function expect_configure_buttons() test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button4", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button4", button_attr.pushed({state_change = false}))) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 50)}) - test.socket.capability:__expect_send(mock_device:generate_test_message("button5", button_attr.pushed({state_change = false}))) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, 60)}) - test.socket.capability:__expect_send(mock_device:generate_test_message("button6", button_attr.pushed({state_change = false}))) end -- All messages queued and expectations set are done before the driver is actually run @@ -155,8 +144,6 @@ local function test_init() for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) 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, "added" }) -- init results in subscription interaction test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -195,6 +182,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 15 } ) @@ -225,6 +215,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 15 } ) @@ -247,7 +240,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.held({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -269,7 +265,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -290,7 +289,10 @@ test.register_coroutine_test( mock_device, 50, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -327,7 +329,10 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button6", button_attr.double({state_change = true}))) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -352,7 +357,10 @@ test.register_coroutine_test( mock_device, 40, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -368,7 +376,10 @@ test.register_coroutine_test( clusters.OccupancySensing.attributes.Occupancy:build_test_report_data(mock_device, 70, 0) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -393,7 +404,10 @@ test.register_coroutine_test( mock_device, 60, {previous_position = 0} ) }) - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -428,6 +442,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({state_change = true})) } +}, +{ + min_api_version = 15 } ) @@ -464,6 +481,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({state_change = true})) }, + }, + { + min_api_version = 15 } ) @@ -509,6 +529,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 15 } ) @@ -530,6 +553,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -551,6 +577,9 @@ test.register_message_test( message = mock_device:generate_test_message("button6", capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -572,6 +601,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "double", "pushed_3x", "pushed_4x", "pushed_5x", "pushed_6x"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -609,6 +641,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.double({state_change = true})) }, +}, +{ + min_api_version = 15 } ) @@ -646,6 +681,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", button_attr.pushed_4x({state_change = true})) }, +}, +{ + min_api_version = 15 } ) @@ -667,6 +705,9 @@ test.register_message_test( message = mock_device:generate_test_message("button5", capabilities.button.supportedButtonValues({"pushed", "double"}, {visibility = {displayed = false}})) }, + }, + { + min_api_version = 15 } ) @@ -708,6 +749,9 @@ test.register_message_test( } } -- no double event +}, +{ + min_api_version = 15 } ) @@ -763,6 +807,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button6", button_attr.pushed({state_change = true})) } + }, + { + min_api_version = 15 } ) -- run the tests 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 49fd69d0ec..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 @@ -7,12 +7,8 @@ local t_utils = require "integration_test.utils" local clusters = require "st.matter.generated.zap_clusters" -local TRANSITION_TIME = 0 -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 local button_attr = capabilities.button.button - local mock_device_ep1 = 1 local mock_device_ep2 = 2 local mock_device_ep3 = 3 @@ -102,7 +98,7 @@ local mock_device = test.mock_device.build_test_matter_device({ local mock_device_mcd_unsupported_switch_device_type = test.mock_device.build_test_matter_device({ label = "Matter Switch", - profile = t_utils.get_profile_definition("matter-thing.yml"), + profile = t_utils.get_profile_definition("button.yml"), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -166,7 +162,18 @@ local child_data = { local mock_child = test.mock_device.build_test_child_device(child_data) -- add device for each mock device -local CLUSTER_SUBSCRIBE_LIST ={ +local CLUSTER_SUBSCRIBE_LIST_NO_CHILD ={ + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, +} + +local CLUSTER_SUBSCRIBE_LIST_WITH_CHILD ={ clusters.OnOff.attributes.OnOff, clusters.LevelControl.attributes.CurrentLevel, clusters.LevelControl.attributes.MaxLevel, @@ -187,26 +194,19 @@ local CLUSTER_SUBSCRIBE_LIST ={ local function expect_configure_buttons() test.socket.capability:__expect_send(mock_device:generate_test_message("button1", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button1", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button3", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = false}))) end local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) -- make sure the cache is populated - test.mock_device.add_test_device(mock_child) - local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + -- added sets a bunch of fields on the device, and calls init + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_NO_CHILD[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_NO_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, "added" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) @@ -225,12 +225,6 @@ local function test_init() }) expect_configure_buttons() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - - 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" }) - mock_child:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.socket.device_lifecycle:__queue_receive({ mock_child.id, "doConfigure" }) end -- All messages queued and expectations set are done before the driver is actually run @@ -275,6 +269,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 15 } ) @@ -295,6 +292,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button1", button_attr.pushed({state_change = true})) --should send initial press } +}, +{ + min_api_version = 15 } ) @@ -325,6 +325,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("button2", button_attr.pushed({state_change = true})) } +}, +{ + min_api_version = 15 } ) @@ -347,70 +350,27 @@ test.register_coroutine_test( ) }) test.socket.capability:__expect_send(mock_device:generate_test_message("button3", button_attr.pushed({state_change = true}))) - end -) - -test.register_message_test( - "Switch child device: Set color temperature should send the appropriate commands", + end, { - { - channel = "capability", - direction = "receive", - message = { - mock_child.id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, mock_device_ep5, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, mock_device_ep5) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, mock_device_ep5, 556) - } - }, - { - channel = "capability", - direction = "send", - message = mock_child:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, + min_api_version = 15 } ) test.register_coroutine_test( "Test MCD configuration not including switch for unsupported switch device type, create child device instead", function() + local unsup_mock_device = mock_device_mcd_unsupported_switch_device_type -- added sets a bunch of fields on the device, and calls init - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, + local CLUSTER_SUBSCRIBE_LIST = { clusters.Switch.server.events.InitialPress, clusters.Switch.server.events.LongPress, clusters.Switch.server.events.ShortRelease, clusters.Switch.server.events.MultiPressComplete, } - local unsup_mock_device = mock_device_mcd_unsupported_switch_device_type - local subscribe_request = cluster_subscribe_list[1]:subscribe(unsup_mock_device) - for _, cluster in ipairs(cluster_subscribe_list) do + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(unsup_mock_device) + for _, cluster in ipairs(CLUSTER_SUBSCRIBE_LIST) do subscribe_request:merge(cluster:subscribe(unsup_mock_device)) end - test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "added" }) - test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "init" }) test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) @@ -423,14 +383,23 @@ test.register_coroutine_test( parent_device_id = unsup_mock_device.id, parent_assigned_child_key = string.format("%d", 7) }) + test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) unsup_mock_device:expect_metadata_update({ profile = "2-button" }) unsup_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() local updated_device_profile = t_utils.get_profile_definition("2-button.yml") test.socket.device_lifecycle:__queue_receive(unsup_mock_device:generate_info_changed({ profile = updated_device_profile })) - local subscribe_request = cluster_subscribe_list[1]:subscribe(unsup_mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do + + local CLUSTER_SUBSCRIBE_LIST = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(unsup_mock_device) + for i, cluster in ipairs(CLUSTER_SUBSCRIBE_LIST) do if i > 1 then subscribe_request:merge(cluster:subscribe(unsup_mock_device)) end @@ -438,35 +407,43 @@ test.register_coroutine_test( test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("main", button_attr.pushed({state_change = false}))) - test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("button2", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("button2", button_attr.pushed({state_change = false}))) end, - { test_init = test_init_mcd_unsupported_switch_device_type } + { + test_init = test_init_mcd_unsupported_switch_device_type, + min_api_version = 15 + } ) 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[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) 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() - end + + 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" }) + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( "Test info changed event with parent device profile update", function() - local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) - for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + local subscribe_request = CLUSTER_SUBSCRIBE_LIST_NO_CHILD[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_NO_CHILD) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end end local updated_device_profile = t_utils.get_profile_definition("light-level-3-button.yml") @@ -474,19 +451,46 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) expect_configure_buttons() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( "Test info changed event with matter_version update", function() + test.mock_device.add_test_device(mock_child) test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } })) -- bump sw to 2 mock_child:expect_metadata_update({ profile = "light-color-level" }) mock_device:expect_metadata_update({ profile = "light-level-3-button" }) expect_configure_buttons() - end + end, + { + min_api_version = 15 + } ) +test.register_coroutine_test( + "Test child device initialization, and that subscriptions are initialized correctly", + function () + test.mock_device.add_test_device(mock_child) + 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 + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + mock_child:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ mock_child.id, "doConfigure" }) + end, + { + min_api_version = 15 + } +) -- run the tests test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_device_configuration.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_device_configuration.lua new file mode 100644 index 0000000000..fc729ed3fa --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_device_configuration.lua @@ -0,0 +1,303 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +test.disable_startup_messages() + + +local generic_manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000 } +local generic_matter_version = { hardware = 1, software = 1 } +local root_endpoint = { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } +} + + +local mock_device_onoff_switch_as_server = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0103, device_type_revision = 1} -- On/Off Light Switch + } + } + } +}) + +test.register_coroutine_test( + "Test profile change on init for onoff parent cluster as server", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_server.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_server.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_server.id, "doConfigure" }) + mock_device_onoff_switch_as_server:expect_metadata_update({ profile = "switch-binary" }) + mock_device_onoff_switch_as_server:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_onoff_switch_as_server) end, + min_api_version = 15 + } +) + + +local mock_device_onoff_switch_as_client = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "CLIENT", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0103, device_type_revision = 1} -- On/Off Light Switch + } + } + } +}) + +test.register_coroutine_test( + "Test init for onoff parent cluster as client", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_client.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_client.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_switch_as_client.id, "doConfigure" }) + mock_device_onoff_switch_as_client:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_onoff_switch_as_client) end, + min_api_version = 15 + } +) + + +local mock_device_dimmer_switch_as_server = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = 0x0104, device_type_revision = 1} -- Dimmer Switch + } + } + } +}) + +test.register_coroutine_test( + "Test profile change on init for dimmer parent cluster as server", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer_switch_as_server.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer_switch_as_server.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer_switch_as_server.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_dimmer_switch_as_server.id, + clusters.LevelControl.attributes.Options:write(mock_device_dimmer_switch_as_server, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_dimmer_switch_as_server:expect_metadata_update({ profile = "switch-level" }) + mock_device_dimmer_switch_as_server:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_dimmer_switch_as_server) end, + min_api_version = 15 + } +) + + +local mock_device_plug_with_switch_profile_vendor_override = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("switch-binary.yml"), + manufacturer_info = { vendor_id = 0x142B, product_id = 0x1003}, -- this device has a vendor override to join as a switch instead of a plug + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = 0x010A, device_type_revision = 1} -- OnOff PlugIn Unit + } + } + } +}) + +test.register_coroutine_test( + "Test init for device with requiring the switch category as a vendor override", + function() + local mock_device = mock_device_plug_with_switch_profile_vendor_override + local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "switch-binary" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_plug_with_switch_profile_vendor_override) end, + min_api_version = 15 + } +) + + +local mock_device_color_dimmer = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "CLIENT", feature_map = 2}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "CLIENT", feature_map = 31}, + + }, + device_types = { + {device_type_id = 0x0105, device_type_revision = 1} -- Color Dimmer Switch + } + } + } +}) + +test.register_coroutine_test( + "Test profile change on init for color dimmer device type as server", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "init" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "doConfigure" }) + mock_device_color_dimmer:expect_metadata_update({ profile = "switch-color-level" }) + mock_device_color_dimmer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_color_dimmer) end, + min_api_version = 15 + } +) + + +local mock_device_mounted_on_off_control = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("switch-binary.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + + }, + device_types = { + {device_type_id = 0x010F, device_type_revision = 1} -- Mounted On/Off Control + } + } + } +}) + +test.register_coroutine_test( + "Test init for mounted onoff control", + function() + local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device_mounted_on_off_control) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "init" }) + test.socket.matter:__expect_send({mock_device_mounted_on_off_control.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_mounted_on_off_control.id, + clusters.LevelControl.attributes.Options:write(mock_device_mounted_on_off_control, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_mounted_on_off_control:expect_metadata_update({ profile = "switch-binary" }) + mock_device_mounted_on_off_control:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_mounted_on_off_control) end, + min_api_version = 15 + } +) + + +local mock_device_mounted_dimmable_load_control = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("switch-level.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + + }, + device_types = { + {device_type_id = 0x0110, device_type_revision = 1} -- Mounted Dimmable Load Control + } + } + } +}) + +test.register_coroutine_test( + "Test init for mounted dimmable load control", + function() + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.LevelControl.attributes.MaxLevel, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_mounted_dimmable_load_control) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_mounted_dimmable_load_control)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "init" }) + test.socket.matter:__expect_send({mock_device_mounted_dimmable_load_control.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_mounted_dimmable_load_control.id, + clusters.LevelControl.attributes.Options:write(mock_device_mounted_dimmable_load_control, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_mounted_dimmable_load_control:expect_metadata_update({ profile = "switch-level" }) + mock_device_mounted_dimmable_load_control:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_mounted_dimmable_load_control) end, + min_api_version = 15 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_parent_child.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_parent_child.lua new file mode 100644 index 0000000000..02a01dc44e --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_on_off_parent_child.lua @@ -0,0 +1,881 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" + +test.disable_startup_messages() + +local generic_manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000 } +local generic_matter_version = { hardware = 1, software = 1 } +local root_endpoint = { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } +} + +local parent_ep_id = 10 +local dimmable_ep_id = 30 +local extended_color_ep_id = 50 + +-- this parent device would fingerprint as light-color-level, since the most feature-rich endpoint is the extended color one, +-- but it should re-configure to light-binary in doConfigure +local mock_device = test.mock_device.build_test_matter_device({ + label = "Matter Switch", + profile = t_utils.get_profile_definition("light-color-level.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = parent_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light + } + }, + { + endpoint_id = extended_color_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + }, + device_types = { + {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light + } + }, + { + endpoint_id = dimmable_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 2}, -- On/Off Light + {device_type_id = 0x0101, device_type_revision = 2} -- Dimmable Light + } + }, + } +}) + +local child_profiles = { + [dimmable_ep_id] = t_utils.get_profile_definition("light-level.yml"), + [extended_color_ep_id] = t_utils.get_profile_definition("light-color-level.yml"), +} + +local mock_children = {} +for i, endpoint in ipairs(mock_device.endpoints) do + if endpoint.endpoint_id ~= parent_ep_id and endpoint.endpoint_id ~= 0 then + local child_data = { + profile = child_profiles[endpoint.endpoint_id], + device_network_id = string.format("%s:%d", mock_device.id, endpoint.endpoint_id), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) + } + mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) + end +end + +local function handle_init_event(mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.CurrentHue, + clusters.ColorControl.attributes.CurrentSaturation, + clusters.ColorControl.attributes.CurrentX, + clusters.ColorControl.attributes.CurrentY, + clusters.ColorControl.attributes.ColorMode, + } + local expected_subscriptions = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + expected_subscriptions:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, expected_subscriptions}) +end + +local function handle_do_configure_event(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, extended_color_ep_id, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, extended_color_ep_id, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, dimmable_ep_id, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + + mock_device:expect_metadata_update({ profile = "light-binary" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 2", + profile = "light-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", dimmable_ep_id) + }) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 3", + profile = "light-color-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", extended_color_ep_id) + }) +end + +local function test_init_for_lifecycle_tests() + test.mock_device.add_test_device(mock_device) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end +end + +-- due to device copy logic in the integration tests, we need to handle init and doConfigure before generating an infoChanged event +local function test_init_for_generate_info_changed_tests() + test.mock_device.add_test_device(mock_device) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end + handle_init_event(mock_device) + handle_do_configure_event(mock_device) +end + +local function test_init_for_post_configure_tests() + test.mock_device.add_test_device(mock_device) + for _, child in pairs(mock_children) do + test.mock_device.add_test_device(child) + end + local FIND_CHILD_KEY = "__find_child_fn" + mock_device:set_field(FIND_CHILD_KEY, switch_utils.find_child, { persist = false }) + mock_device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, { persist = false }) +end + +test.set_test_init_function(test_init_for_post_configure_tests) + +test.register_coroutine_test( + "Handle initial init lifecycle event, before children are created", + function() + handle_init_event(mock_device) + test.wait_for_events() + assert(mock_device:get_field(fields.profiling_data.POWER_TOPOLOGY) == false, "Device should be marked as not needing to configure power topology") + assert(mock_device:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as having no battery") + end, + { + test_init = test_init_for_lifecycle_tests, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Handle doConfigure lifecycle event", + function() + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, false, { persist = true }) + mock_device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + handle_do_configure_event(mock_device) + test.wait_for_events() + local FIND_CHILD_KEY = "__find_child_fn" + assert(type(mock_device:get_field(FIND_CHILD_KEY)) == "function", "Child find function should be stored in doConfigure") + end, + { + test_init = test_init_for_lifecycle_tests, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Test info changed event with matter_version update", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } })) -- bump to 2 + mock_children[dimmable_ep_id]:expect_metadata_update({ profile = "light-level" }) + mock_children[extended_color_ep_id]:expect_metadata_update({ profile = "light-color-level" }) + mock_device:expect_metadata_update({ profile = "light-binary" }) + end, + { + test_init = test_init_for_generate_info_changed_tests, + min_api_version = 15 + } +) + + +test.register_message_test( + "Dimmable Child: Current level cluster reports generate switch level events appropriately", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, dimmable_ep_id, 50) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[dimmable_ep_id]:generate_test_message("main", capabilities.switchLevel.level(math.floor((50 / 254.0 * 100) + 0.5))) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + } + }, + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Children: Level Control Min and max attributes set switch level constraints appropriately", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, dimmable_ep_id, 1) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, dimmable_ep_id, 254) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[dimmable_ep_id]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 1, maximum = 100})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, extended_color_ep_id, 127) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, extended_color_ep_id, 203) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 50, maximum = 80})) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Extended Color Child: SetColor command should be handled correctly", + { + { + channel = "capability", + direction = "receive", + message = { + mock_children[extended_color_ep_id].id, + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } } } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColor(mock_device, extended_color_ep_id, 15182, 21547, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_device, extended_color_ep_id) + } + }, + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Extended Color Child: X and Y color values should report hue and saturation once both have been received", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, extended_color_ep_id, 15091) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, extended_color_ep_id, 21547) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.colorControl.hue(50)) + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.colorControl.saturation(72)) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Extended Color Child: colorTemperatureRange, setColorTemperature, colorTemperature, stepColorTemperatureByPercent handled appropriately", + { + -- colorTemperatureRange testing + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, extended_color_ep_id, 153) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, extended_color_ep_id, 555) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) + }, + + -- setColorTemperature testing + { + channel = "capability", + direction = "receive", + message = { + mock_children[extended_color_ep_id].id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_children[extended_color_ep_id].id, capability_id = "colorTemperature", capability_cmd_id = "setColorTemperature" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, extended_color_ep_id, 555, fields.ZERO_TRANSITION_TIME, fields.OPTIONS_MASK, fields.HANDLE_COMMAND_IF_OFF) + } + }, -- 555 is expected since it is re-bounded by the given range + + -- colorTemperature testing + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, extended_color_ep_id, 555) + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) + }, + + -- stepColorTemperatureByPercent testing + { + channel = "capability", + direction = "receive", + message = { + mock_children[extended_color_ep_id].id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_children[extended_color_ep_id].id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.ColorControl.server.commands.StepColorTemperature(mock_device, extended_color_ep_id, clusters.ColorControl.types.StepModeEnum.DOWN, 80, fields.DEFAULT_STEP_TRANSITION_TIME, 153, 555, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Parent: switch capability <-> On Off cluster should handle events appropriately", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "switch", component = "main", command = "on", args = { } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, parent_ep_id) + }, + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, parent_ep_id, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Children: switch capability <-> On Off Cluster should handle events appropriately", + { + { + channel = "capability", + direction = "receive", + message = { + mock_children[dimmable_ep_id].id, + { capability = "switch", component = "main", command = "on", args = { } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_children[dimmable_ep_id].id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, dimmable_ep_id) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, dimmable_ep_id, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[dimmable_ep_id]:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_children[extended_color_ep_id].id, + { capability = "switch", component = "main", command = "on", args = { } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_children[extended_color_ep_id].id, capability_id = "switch", capability_cmd_id = "on" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, extended_color_ep_id) + } + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, extended_color_ep_id, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_children[extended_color_ep_id]:generate_test_message("main", capabilities.switch.switch.on()) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + } + }, + }, + { + min_api_version = 15 + } +) + +local dimmable_child_plug_ep_id = 30 + +local mock_plug = test.mock_device.build_test_matter_device({ + label = "Matter Plug", + profile = t_utils.get_profile_definition("plug-level.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = parent_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT, device_type_revision = 2} + } + }, + { + endpoint_id = dimmable_child_plug_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.DIMMABLE_PLUG_IN_UNIT, device_type_revision = 2} + } + }, + } +}) + +local function handle_init_event_for_plug(mock_device) + local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel + } + local expected_subscriptions = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + expected_subscriptions:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, expected_subscriptions}) +end + +local function handle_do_configure_event_for_plug(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, dimmable_child_plug_ep_id, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) + mock_device:expect_metadata_update({ profile = "plug-binary" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Plug 2", + profile = "plug-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", dimmable_child_plug_ep_id) + }) +end + +test.register_coroutine_test( + "Plug: Handle initial init lifecycle event, before children are created", + function() + handle_init_event_for_plug(mock_plug) + test.wait_for_events() + assert(mock_plug:get_field(fields.profiling_data.POWER_TOPOLOGY) == false, "Device should be marked as not needing to configure power topology") + assert(mock_plug:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as having no battery") + end, + { + test_init = function() + test.mock_device.add_test_device(mock_plug) + end, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Plug: Handle doConfigure lifecycle event", + function() + mock_plug:set_field(fields.profiling_data.BATTERY_SUPPORT, false, { persist = true }) + mock_plug:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + handle_do_configure_event_for_plug(mock_plug) + test.wait_for_events() + local FIND_CHILD_KEY = "__find_child_fn" + assert(type(mock_plug:get_field(FIND_CHILD_KEY)) == "function", "Child find function should be stored in doConfigure") + end, + { + test_init = function() + test.mock_device.add_test_device(mock_plug) + end, + min_api_version = 15 + } +) + + +local overriden_plug_child_ep_id = 30 + +-- This device overrides both its parent and child profiles to become the Switch category +local mock_plug_profile_override = test.mock_device.build_test_matter_device({ + label = "Matter Plug", + profile = t_utils.get_profile_definition("switch-binary.yml"), + manufacturer_info = { vendor_id = 0x1321, product_id = 0x000C }, -- this Sonoff device has an overloaded profile only for its children + endpoints = { + root_endpoint, + { + endpoint_id = parent_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT, device_type_revision = 2} + } + }, + { + endpoint_id = overriden_plug_child_ep_id, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT, device_type_revision = 2} + } + }, + } +}) + + +local function handle_do_configure_event_for_plug_with_profile_override(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "switch-binary" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Plug 2", + profile = "switch-binary", -- overriden profile for Sonoff device + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", overriden_plug_child_ep_id) + }) +end + +test.register_coroutine_test( + "Plug with Overriden Profile: Handle doConfigure lifecycle event", + function() + mock_plug_profile_override:set_field(fields.profiling_data.BATTERY_SUPPORT, false, { persist = true }) + mock_plug_profile_override:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + handle_do_configure_event_for_plug_with_profile_override(mock_plug_profile_override) + end, + { + test_init = function() + test.mock_device.add_test_device(mock_plug_profile_override) + end, + min_api_version = 15 + } +) + +local mock_switch = test.mock_device.build_test_matter_device({ + label = "Matter Switch", + profile = t_utils.get_profile_definition("matter-thing.yml"), + manufacturer_info = generic_manufacturer_info, + matter_version = generic_matter_version, + endpoints = { + root_endpoint, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.SWITCH.DIMMER, device_type_revision = 1} + } + }, + { + endpoint_id = 10, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.SWITCH.ON_OFF_LIGHT, device_type_revision = 1} + } + }, + { + endpoint_id = 20, -- this endpoint should not generate a child device since it only has a client OnOff cluster + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "CLIENT", cluster_revision = 1, feature_map = 0}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.SWITCH.ON_OFF_LIGHT, device_type_revision = 1} + } + }, + { + endpoint_id = 30, -- this endpoint should profile correctly, though it is not a switch + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.ON_OFF, device_type_revision = 2} + } + }, + { + endpoint_id = 40, -- this endpoint should generate a switch-binary child device since it has a SERVER OnOff cluster,though the device type is unknown. + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} + }, + device_types = { + {device_type_id = 0x0304, device_type_revision = 2} -- Pump Controller + } + } + } +}) + +test.register_coroutine_test( + "Switch Profile: Handle doConfigure lifecycle event", + function() + mock_switch:set_field(fields.profiling_data.BATTERY_SUPPORT, false, { persist = true }) + mock_switch:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + test.socket.device_lifecycle:__queue_receive({ mock_switch.id, "doConfigure" }) + test.socket.matter:__expect_send({ mock_switch.id, clusters.LevelControl.attributes.Options:write(mock_switch, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) }) + test.socket.matter:__expect_send({ mock_switch.id, clusters.LevelControl.attributes.Options:write(mock_switch, 40, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) }) + mock_switch:expect_metadata_update({ profile = "switch-level" }) + mock_switch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + mock_switch:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 2", + profile = "switch-binary", + parent_device_id = mock_switch.id, + parent_assigned_child_key = string.format("%d", 10) + }) + + -- client cluster only endpoint (20) should not generate a child device + + mock_switch:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 3", + profile = "light-binary", + parent_device_id = mock_switch.id, + parent_assigned_child_key = string.format("%d", 30) + }) + + mock_switch:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Switch 4", + profile = "switch-binary", + parent_device_id = mock_switch.id, + parent_assigned_child_key = string.format("%d", 40) + }) + end, + { + test_init = function() test.mock_device.add_test_device(mock_switch) end, + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua new file mode 100644 index 0000000000..e6d51d1f36 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua @@ -0,0 +1,93 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("3-button-battery-temperature-humidity.yml"), + matter_version = {hardware = 1, software = 1}, + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1}, + } + }, + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1}, + } + }, + } +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end + +test.register_coroutine_test("Read appropriate attribute values after tempOffset preference change", function() + local report = clusters.TemperatureMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device,1, 2000) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { tempOffset = "2" } })) + + test.socket.matter:__queue_receive({mock_device.id, report}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.temperatureMeasurement.temperature({ + value = 20.0, + unit = "C" + }))) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {tempOffset = "5"}})) + test.socket.matter:__expect_send({mock_device.id, clusters.TemperatureMeasurement.attributes.MeasuredValue:read(mock_device)}) + + test.wait_for_events() + + test.socket.matter:__queue_receive({mock_device.id, report}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.temperatureMeasurement.temperature({ + value = 20.0, + unit = "C" + }))) +end, +{ + min_api_version = 15 +} +) + +test.register_coroutine_test("Read appropriate attribute values after humidityOffset preference change", function() + local report = clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:build_test_report_data(mock_device,2, 2000) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { humidityOffset = "0" } })) + + test.socket.matter:__queue_receive({mock_device.id, report}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.relativeHumidityMeasurement.humidity({ + value = 20 + }))) + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({preferences = {humidityOffset = "5"}})) + test.socket.matter:__expect_send({mock_device.id, clusters.RelativeHumidityMeasurement.attributes.MeasuredValue:read(mock_device)}) + + test.wait_for_events() + + test.socket.matter:__queue_receive({mock_device.id, report}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.relativeHumidityMeasurement.humidity({ + value = 20 + }))) +end, +{ + min_api_version = 15 +} +) + +test.set_test_init_function(test_init) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua index 25bc0fdfaa..d48a89b9cc 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua @@ -4,12 +4,12 @@ local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" -local st_utils = require "st.utils" +local fields = require "switch_utils.fields" local clusters = require "st.matter.clusters" local TRANSITION_TIME = 0 local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 +local HANDLE_COMMAND_IF_OFF = 0x01 local mock_device = test.mock_device.build_test_matter_device({ profile = t_utils.get_profile_definition("switch-color-level.yml"), @@ -27,87 +27,18 @@ local mock_device = test.mock_device.build_test_matter_device({ {device_type_id = 0x0016, device_type_revision = 1} -- RootNode } }, - { - endpoint_id = 1, - clusters = { - { - cluster_id = clusters.OnOff.ID, - cluster_type = "SERVER", - cluster_revision = 1, - feature_map = 0, --u32 bitmap - }, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 31}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 1} -- On/Off Light - } - } - } -}) - -local mock_device_no_hue_sat = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("switch-color-level.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 1} -- On/Off Light - } - } - } -}) - -local mock_device_color_temp = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 1}, -- On/Off Light - {device_type_id = 0x010C, device_type_revision = 1} -- Color Temperature Light - } - } - } -}) - -local mock_device_extended_color = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("light-color-level.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { { endpoint_id = 1, clusters = { {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 31}, {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} }, device_types = { - {device_type_id = 0x0100, device_type_revision = 1}, -- On/Off Light - {device_type_id = 0x0101, device_type_revision = 1}, -- Dimmable Light - {device_type_id = 0x010C, device_type_revision = 1}, -- Color Temperature Light - {device_type_id = 0x010D, device_type_revision = 1}, -- Extended Color Light + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.ON_OFF, device_type_revision = 1}, + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE, device_type_revision = 1}, + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.COLOR_TEMPERATURE, device_type_revision = 1}, + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.EXTENDED_COLOR, device_type_revision = 1} } } } @@ -126,138 +57,61 @@ local cluster_subscribe_list = { clusters.ColorControl.attributes.ColorTemperatureMireds, clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, + clusters.ColorControl.attributes.ColorMode, } -local function set_color_mode(device, endpoint, color_mode) - test.socket.matter:__queue_receive({ - device.id, - clusters.ColorControl.attributes.ColorMode:build_test_report_data( - device, endpoint, color_mode) - }) - local read_req - if color_mode == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then - read_req = clusters.ColorControl.attributes.CurrentHue:read() - read_req:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) - else -- color_mode = clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY - read_req = clusters.ColorControl.attributes.CurrentX:read() - read_req:merge(clusters.ColorControl.attributes.CurrentY:read()) - end - test.socket.matter:__expect_send({device.id, read_req}) -end - local function test_init() - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device)) - end - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.mock_device.add_test_device(mock_device) - set_color_mode(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION) -end -test.set_test_init_function(test_init) -local function test_init_x_y_color_mode() local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) for i, cluster in ipairs(cluster_subscribe_list) do if i > 1 then subscribe_request:merge(cluster:subscribe(mock_device)) end end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) - set_color_mode(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) -end - -local function test_init_no_hue_sat() - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_no_hue_sat) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_no_hue_sat)) - end - end - test.socket.matter:__expect_send({mock_device_no_hue_sat.id, subscribe_request}) - test.mock_device.add_test_device(mock_device_no_hue_sat) - set_color_mode(mock_device_no_hue_sat, 1, clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) -end - - -local cluster_subscribe_list_color_temp = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds -} - -local function test_init_color_temp() - test.mock_device.add_test_device(mock_device_color_temp) - local subscribe_request = cluster_subscribe_list_color_temp[1]:subscribe(mock_device_color_temp) - for i, cluster in ipairs(cluster_subscribe_list_color_temp) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_color_temp)) - end - end - - test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "added" }) - test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "init" }) - test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "doConfigure" }) - test.socket.matter:__expect_send({ - mock_device_color_temp.id, - clusters.LevelControl.attributes.Options:write(mock_device_color_temp, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - test.socket.matter:__expect_send({ - mock_device_color_temp.id, - clusters.ColorControl.attributes.Options:write(mock_device_color_temp, 1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_color_temp:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) end -local function test_init_extended_color() - test.mock_device.add_test_device(mock_device_extended_color) - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_extended_color) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_extended_color)) - end - end - test.socket.matter:__expect_send({mock_device_extended_color.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_device_extended_color.id, "added" }) - - test.socket.device_lifecycle:__queue_receive({ mock_device_extended_color.id, "init" }) - test.socket.matter:__expect_send({mock_device_extended_color.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_extended_color.id, "doConfigure" }) - test.socket.matter:__expect_send({ - mock_device_extended_color.id, - clusters.LevelControl.attributes.Options:write(mock_device_extended_color, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - test.socket.matter:__expect_send({ - mock_device_extended_color.id, - clusters.ColorControl.attributes.Options:write(mock_device_extended_color, 1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_extended_color:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - test.socket.matter:__expect_send({mock_device_extended_color.id, subscribe_request}) -end +test.set_test_init_function(test_init) -test.register_message_test( - "Test that Color Temperature Light device does not switch profiles", - {}, - { test_init = test_init_color_temp } +test.register_coroutine_test( + "doConfigure properly sets Extended Color Light and does not attempt to switch profiles", + function() + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, { persist = true }) + mock_device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.LevelControl.attributes.Options:write(mock_device, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ColorControl.attributes.Options:write(mock_device, 1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14 + } ) -test.register_message_test( - "Test that Extended Color Light device does not switch profiles", - {}, - { test_init = test_init_extended_color } +test.register_coroutine_test( + "init creates accurate subscription for Extended Color Light", + function() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -287,6 +141,9 @@ test.register_message_test( clusters.OnOff.server.commands.On(mock_device, 1) } } + }, + { + min_api_version = 14 } ) @@ -317,26 +174,49 @@ test.register_message_test( clusters.OnOff.server.commands.Off(mock_device, 1) } } + }, + { + min_api_version = 14 } ) + +local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) +local sat = math.floor((50 * 0xFE) / 100.0 + 0.5) test.register_message_test( - "Set level command should send the appropriate commands", + "Set Hue command should send MoveToHue", { { channel = "capability", direction = "receive", message = { mock_device.id, - { capability = "switchLevel", component = "main", command = "setLevel", args = {20,20} } + { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } } }, { - channel = "devices", + channel = "matter", direction = "send", message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switchLevel", capability_cmd_id = "setLevel" } + mock_device.id, + clusters.ColorControl.server.commands.MoveToHue(mock_device, 1, hue, 0, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) + } + }, + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Set Saturation command should send MoveToSaturation", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } } }, { @@ -344,36 +224,56 @@ test.register_message_test( direction = "send", message = { mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff(mock_device, 1, st_utils.round(20/100.0 * 254), 20, 0 ,0) + clusters.ColorControl.server.commands.MoveToSaturation(mock_device, 1, sat, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Set color temperature should send the appropriate commands", + { { - channel = "matter", + channel = "capability", direction = "receive", message = { mock_device.id, - clusters.LevelControl.server.commands.MoveToLevelWithOnOff:build_test_command_response(mock_device, 1) + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_cmd_id = "setColorTemperature" } } }, { channel = "matter", - direction = "receive", + direction = "send", message = { mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 556, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(20)) + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, 1) + } }, { channel = "devices", direction = "send", message = { "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } } }, { @@ -381,127 +281,114 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 1, true) + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 556) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) + }, + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Do not report when receiving a color temperature of 0 mireds", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 0) + } }, { channel = "devices", direction = "send", message = { "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } } - }, - + } + }, + { + min_api_version = 14 } ) test.register_message_test( - "Current level reports should generate appropriate events", + "Min and max color temperature attributes set capability constraint", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, 1, 50) + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 153) } }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.level(st_utils.round((50 / 254.0 * 100) + 0.5))) - }, - { - channel = "devices", - direction = "send", + channel = "matter", + direction = "receive", message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 555) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) + } + }, + { + min_api_version = 14 } ) -test.register_coroutine_test( - "Set color command should send the appropriate commands", function() - test.socket.capability:__queue_receive( - { - mock_device_no_hue_sat.id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } }}, +test.register_message_test( + "Min and max color temperature attributes set capability constraint using improved temperature conversion rounding", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) } - ) - test.socket.matter:__expect_send( - { - mock_device_no_hue_sat.id, - clusters.ColorControl.server.commands.MoveToColor(mock_device_no_hue_sat, 1, 15182, 21547, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - ) - test.socket.matter:__queue_receive( - { - mock_device_no_hue_sat.id, - clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_device_no_hue_sat, 1) - } - ) - test.socket.matter:__queue_receive( - { - mock_device_no_hue_sat.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device_no_hue_sat, 1, 15091) - } - ) - test.socket.matter:__queue_receive( - { - mock_device_no_hue_sat.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device_no_hue_sat, 1, 21547) - } - ) - test.socket.capability:__expect_send( - mock_device_no_hue_sat:generate_test_message( - "main", capabilities.colorControl.hue(50) - ) - ) - test.socket.capability:__expect_send( - mock_device_no_hue_sat:generate_test_message( - "main", capabilities.colorControl.saturation(72) - ) - ) - end, - { test_init = test_init_no_hue_sat } -) - -local hue = math.floor((50 * 0xFE) / 100.0 + 0.5) -local sat = math.floor((50 * 0xFE) / 100.0 + 0.5) - -test.register_message_test( - "Set color command should send huesat commands when supported", - { + }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) } }, { channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } - } - }, + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Device reports mireds outside of supported range, set capability to min/max value in kelvin", + { { channel = "matter", - direction = "send", + direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) } }, { @@ -509,207 +396,229 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) + }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 160) } }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) - }, { channel = "devices", direction = "send", message = { "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(6000)) + }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) + clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 370) } }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(50)) - }, { channel = "devices", direction = "send", message = { "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_attr_id = "colorTemperature" } } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(2800)) + } + }, + { + min_api_version = 14 } ) -hue = 0xFE -sat = 0xFE test.register_message_test( - "Set color command should clamp invalid huesat values", + "Capability sets color temp outside of supported range, value sent to device is limited to min/max value in mireds", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) } }, { - channel = "capability", + channel = "matter", direction = "receive", message = { mock_device.id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 110, saturation = 110 } } } + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) } }, { - channel = "matter", + channel = "capability", direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } + message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) }, { - channel = "matter", + channel = "capability", direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {6100} } } }, { - channel = "matter", - direction = "receive", + channel = "devices", + direction = "send", message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_cmd_id = "setColorTemperature" } } }, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.hue(100)) - }, - { - channel = "devices", + channel = "matter", direction = "send", message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 165, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, { - channel = "matter", + channel = "capability", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {2700} } } }, { - channel = "capability", + channel = "devices", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "colorTemperature", capability_cmd_id = "setColorTemperature" } + } }, { - channel = "devices", + channel = "matter", direction = "send", message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + mock_device.id, + clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 365, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } - }, + } + }, + { + min_api_version = 14 } ) -hue = math.floor((50 * 0xFE) / 100.0 + 0.5) -sat = math.floor((50 * 0xFE) / 100.0 + 0.5) test.register_message_test( - "Set Hue command should send MoveToHue", + "Min color temperature outside of range, capability not sent", { { - channel = "capability", + channel = "matter", direction = "receive", message = { mock_device.id, - { capability = "colorControl", component = "main", command = "setHue", args = { 50 } } + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 50) } }, { channel = "matter", - direction = "send", + direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToHue(mock_device, 1, hue, 0, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 555) } - }, + } + }, + { + min_api_version = 14 } ) test.register_message_test( - "Set Saturation command should send MoveToSaturation", + "Max color temperature outside of range, capability not sent", { { - channel = "capability", + channel = "matter", direction = "receive", message = { mock_device.id, - { capability = "colorControl", component = "main", command = "setSaturation", args = { 50 } } + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 153) } }, { channel = "matter", - direction = "send", + direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToSaturation(mock_device, 1, sat, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 1100) } - }, + } + }, + { + min_api_version = 14 } ) test.register_message_test( - "Set color temperature should send the appropriate commands", + "Min and max level attributes set capability constraint", { { - channel = "capability", + channel = "matter", direction = "receive", message = { mock_device.id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } + clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, 1, 5) } }, { channel = "matter", - direction = "send", + direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, 1, 10) } }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 2, maximum = 4})) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Min level attribute outside of range for lighting feature device (min level = 1), capability not sent", + { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, 1) + clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, 1, 0) } }, { @@ -717,88 +626,124 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 556) + clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, 1, 10) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, + } + }, + { + min_api_version = 14 } ) test.register_coroutine_test( - "X and Y color values should report hue and saturation once both have been received", + "colorControl capability sent based on CurrentX and CurrentY due to ColorMode", function() test.socket.matter:__queue_receive( { mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + clusters.ColorControl.attributes.ColorMode:build_test_report_data(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) } ) - test.socket.matter:__queue_receive( + local read_x_y = clusters.ColorControl.attributes.CurrentX:read() + read_x_y:merge(clusters.ColorControl.attributes.CurrentY:read()) + test.socket.matter:__expect_send( { mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + read_x_y } ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.hue(50) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.saturation(72) - ) - ) - end, - { test_init = test_init_x_y_color_mode } -) - -test.register_coroutine_test( - "X and Y color values have 0 value", - function() test.socket.matter:__queue_receive( { mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 0) + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) } ) test.socket.matter:__queue_receive( { mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 0) + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.colorControl.hue(33) + "main", capabilities.colorControl.hue(50) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.colorControl.saturation(100) + "main", capabilities.colorControl.saturation(72) ) ) end, - { test_init = test_init_x_y_color_mode } + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Y and X color values should report hue and saturation once both have been received", + "Refresh necessary attributes", function() - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) - } + test.socket.capability:__queue_receive( + {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} ) - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + local read_request = cluster_subscribe_list[1]:read(mock_device) + for i, attr in ipairs(cluster_subscribe_list) do + if i > 1 then read_request:merge(attr:read(mock_device)) end + end + test.socket.matter:__expect_send({mock_device.id, read_request}) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + + +local function set_color_mode(device, endpoint, color_mode) + test.socket.matter:__queue_receive({ + device.id, + clusters.ColorControl.attributes.ColorMode:build_test_report_data( + device, endpoint, color_mode) + }) + local read_req + if color_mode == clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION then + read_req = clusters.ColorControl.attributes.CurrentHue:read() + read_req:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) + else -- color_mode = clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY + read_req = clusters.ColorControl.attributes.CurrentX:read() + read_req:merge(clusters.ColorControl.attributes.CurrentY:read()) + end + test.socket.matter:__expect_send({device.id, read_req}) +end + + + +local function test_init_x_y_color_mode() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + set_color_mode(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) +end + +test.register_coroutine_test( + "X and Y color values should report hue and saturation once both have been received", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) } ) test.socket.capability:__expect_send( @@ -812,139 +757,181 @@ test.register_coroutine_test( ) ) end, - { test_init = test_init_x_y_color_mode } + { + test_init = test_init_x_y_color_mode, + min_api_version = 14 + } ) -test.register_message_test( - "Do not report when receiving a color temperature of 0 mireds", - { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "X and Y color values have 0 value", + function() + test.socket.matter:__queue_receive( + { mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 0) + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 0) } - } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 0) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.hue(33) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.saturation(100) + ) + ) + end, + { + test_init = test_init_x_y_color_mode, + min_api_version = 14 } ) -test.register_message_test( - "Min and max color temperature attributes set capability constraint", - { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "Y and X color values should report hue and saturation once both have been received", + function() + test.socket.matter:__queue_receive( + { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 153) + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) } - }, - { - channel = "matter", - direction = "receive", - message = { + ) + test.socket.matter:__queue_receive( + { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 555) + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) - } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.hue(50) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.saturation(72) + ) + ) + end, + { + test_init = test_init_x_y_color_mode, + min_api_version = 14 } ) -test.register_message_test( - "Min and max color temperature attributes set capability constraint using improved temperature conversion rounding", - { - { - channel = "matter", - direction = "receive", - message = { +test.register_coroutine_test( + "colorControl capability sent based on CurrentHue and CurrentSaturation due to ColorMode", + function() + test.socket.matter:__queue_receive( + { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) + clusters.ColorControl.attributes.ColorMode:build_test_report_data(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION) } - }, - { - channel = "matter", - direction = "receive", - message = { + ) + local read_hue_sat = clusters.ColorControl.attributes.CurrentHue:read() + read_hue_sat:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) + test.socket.matter:__expect_send( + { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) + read_hue_sat } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) - } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, 0xFE), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.hue(100) + ) + ) + test.socket.devices:__expect_send( + { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } + } + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, 0xFE), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.colorControl.saturation(100) + ) + ) + test.socket.devices:__expect_send( + { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + } + ) + end, + { + test_init = test_init_x_y_color_mode, + min_api_version = 14 } ) + + +local function test_init_with_hue_sat_color_mode_set() + test.mock_device.add_test_device(mock_device) + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + set_color_mode(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION) +end + test.register_message_test( - "Device reports mireds outside of supported range, set capability to min/max value in kelvin", + "Set color command should send huesat commands when supported", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) + clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) } }, { channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) - }, - { - channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 160) + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 50 } } } } }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(6000)) - }, { channel = "matter", - direction = "receive", + direction = "send", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, 1, 370) + clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(2800)) - } - } -) - -test.register_message_test( - "Capability sets color temp outside of supported range, value sent to device is limited to min/max value in mireds", - { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 165) + clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) } }, { @@ -952,80 +939,77 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 365) + clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2800, maximum = 6000})) + message = mock_device:generate_test_message("main", capabilities.colorControl.hue(50)) }, { - channel = "capability", - direction = "receive", + channel = "devices", + direction = "send", message = { - mock_device.id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {6100} } + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } } }, { channel = "matter", - direction = "send", + direction = "receive", message = { mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 165, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) } }, { channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {2700} } - } + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(50)) }, { - channel = "matter", + channel = "devices", direction = "send", message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, 1, 365, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } } - } + }, + }, + { + test_init = test_init_with_hue_sat_color_mode_set, + min_api_version = 14 } ) +hue = 0xFE +sat = 0xFE test.register_message_test( - "Min color temperature outside of range, capability not sent", + "Set color command should clamp invalid huesat values", { { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 50) + clusters.ColorControl.attributes.ColorCapabilities:build_test_report_data(mock_device, 1, 0x01) } }, { - channel = "matter", + channel = "capability", direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 555) + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 110, saturation = 110 } } } } - } - } -) - -test.register_message_test( - "Max color temperature outside of range, capability not sent", - { + }, { channel = "matter", - direction = "receive", + direction = "send", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, 1, 153) + clusters.ColorControl.server.commands.MoveToHueAndSaturation(mock_device, 1, hue, sat, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } }, { @@ -1033,21 +1017,28 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, 1, 1100) + clusters.ColorControl.server.commands.MoveToHueAndSaturation:build_test_command_response(mock_device, 1) } - } - } -) - -test.register_message_test( - "Min and max level attributes set capability constraint", - { + }, { channel = "matter", direction = "receive", message = { mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, 1, 5) + clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, hue) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.colorControl.hue(100)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } } }, { @@ -1055,134 +1046,184 @@ test.register_message_test( direction = "receive", message = { mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, 1, 10) + clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, sat) } }, { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 2, maximum = 4})) - } - } -) - -test.register_message_test( - "Min level attribute outside of range for lighting feature device (min level = 1), capability not sent", - { + message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + }, { - channel = "matter", - direction = "receive", + channel = "devices", + direction = "send", message = { - mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, 1, 0) + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } } }, + }, + { + test_init = test_init_with_hue_sat_color_mode_set, + min_api_version = 14 + } +) + + + + +local mock_color_device_no_hue_sat = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-color-level.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, 1, 10) + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.EXTENDED_COLOR, device_type_revision = 1} } } } -) +}) + +local function test_init_no_hue_sat() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_color_device_no_hue_sat) +end test.register_coroutine_test( - "colorControl capability sent based on CurrentHue and CurrentSaturation due to ColorMode", - function() - test.socket.matter:__queue_receive( + "Set color command should send the appropriate commands", function() + test.socket.capability:__queue_receive( { - mock_device.id, - clusters.ColorControl.attributes.ColorMode:build_test_report_data(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENT_HUE_AND_CURRENT_SATURATION) + mock_color_device_no_hue_sat.id, + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } }}, } ) - local read_hue_sat = clusters.ColorControl.attributes.CurrentHue:read() - read_hue_sat:merge(clusters.ColorControl.attributes.CurrentSaturation:read()) test.socket.matter:__expect_send( { - mock_device.id, - read_hue_sat + mock_color_device_no_hue_sat.id, + clusters.ColorControl.server.commands.MoveToColor(mock_color_device_no_hue_sat, 1, 15182, 21547, TRANSITION_TIME, OPTIONS_MASK, HANDLE_COMMAND_IF_OFF) } ) test.socket.matter:__queue_receive( { - mock_device.id, - clusters.ColorControl.attributes.CurrentHue:build_test_report_data(mock_device, 1, 0xFE), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.hue(100) - ) - ) - test.socket.devices:__expect_send( - { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } + mock_color_device_no_hue_sat.id, + clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_color_device_no_hue_sat, 1) } ) test.socket.matter:__queue_receive( { - mock_device.id, - clusters.ColorControl.attributes.CurrentSaturation:build_test_report_data(mock_device, 1, 0xFE), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.colorControl.saturation(100) - ) - ) - test.socket.devices:__expect_send( - { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + mock_color_device_no_hue_sat.id, + clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_color_device_no_hue_sat, 1, 15091) } ) - end, - { test_init = test_init_x_y_color_mode } -) - -test.register_coroutine_test( - "colorControl capability sent based on CurrentX and CurrentY due to ColorMode", - function() test.socket.matter:__queue_receive( { - mock_device.id, - clusters.ColorControl.attributes.ColorMode:build_test_report_data(mock_device, 1, clusters.ColorControl.types.ColorMode.CURRENTX_AND_CURRENTY) - } - ) - local read_x_y = clusters.ColorControl.attributes.CurrentX:read() - read_x_y:merge(clusters.ColorControl.attributes.CurrentY:read()) - test.socket.matter:__expect_send( - { - mock_device.id, - read_x_y - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, 1, 15091) - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, 1, 21547) + mock_color_device_no_hue_sat.id, + clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_color_device_no_hue_sat, 1, 21547) } ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_color_device_no_hue_sat:generate_test_message( "main", capabilities.colorControl.hue(50) ) ) test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_color_device_no_hue_sat:generate_test_message( "main", capabilities.colorControl.saturation(72) ) ) - end + end, + { + test_init = test_init_no_hue_sat, + min_api_version = 15 + } +) + + + +local mock_device_color_temp = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.ON_OFF, device_type_revision = 1}, + {device_type_id = fields.DEVICE_TYPE_ID.LIGHT.COLOR_TEMPERATURE, device_type_revision = 1} + } + } + } +}) + +local function test_init_color_temp() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_color_temp) +end + +test.register_coroutine_test( + "doConfigure properly sets Color Temperature Light and does not attempt to switch profiles", + function() + mock_device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, { persist = true }) + mock_device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, { persist = true }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "doConfigure" }) + test.socket.matter:__expect_send({ + mock_device_color_temp.id, + clusters.LevelControl.attributes.Options:write(mock_device_color_temp, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + test.socket.matter:__expect_send({ + mock_device_color_temp.id, + clusters.ColorControl.attributes.Options:write(mock_device_color_temp, 1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF) + }) + mock_device_color_temp:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = test_init_color_temp, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "init creates accurate subscription for Color Temperature Light", + function() + local cluster_subscribe_list_color_temp = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds + } + local subscribe_request = cluster_subscribe_list_color_temp[1]:subscribe(mock_device_color_temp) + for i, cluster in ipairs(cluster_subscribe_list_color_temp) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_color_temp)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "init" }) + test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) + end, + { + test_init = test_init_color_temp, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_switch_device_types.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_switch_device_types.lua deleted file mode 100644 index f90855a308..0000000000 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_switch_device_types.lua +++ /dev/null @@ -1,789 +0,0 @@ --- Copyright © 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local clusters = require "st.matter.clusters" - -test.disable_startup_messages() - -local mock_device_onoff = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("matter-thing.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - matter_version = { - hardware = 1, - software = 1, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0103, device_type_revision = 1} -- On/Off Light Switch - } - } - } -}) - -local mock_device_onoff_client = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("matter-thing.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "CLIENT", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0103, device_type_revision = 1} -- On/Off Light Switch - } - } - } -}) - -local mock_device_dimmer = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("matter-thing.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 5, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0104, device_type_revision = 1} -- Dimmer Switch - } - } - } -}) - -local mock_device_switch_vendor_override = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("switch-binary.yml"), - manufacturer_info = { - vendor_id = 0x109B, - product_id = 0x1001, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x010A, device_type_revision = 1} -- OnOff PlugIn Unit - } - } - } -}) - - -local mock_device_color_dimmer = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("matter-thing.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 7, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "CLIENT", feature_map = 2}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "CLIENT", feature_map = 31}, - - }, - device_types = { - {device_type_id = 0x0105, device_type_revision = 1} -- Color Dimmer Switch - } - } - } -}) - -local mock_device_mounted_on_off_control = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("switch-binary.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 7, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - - }, - device_types = { - {device_type_id = 0x010F, device_type_revision = 1} -- Mounted On/Off Control - } - } - } -}) - -local mock_device_mounted_dimmable_load_control = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("switch-level.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 7, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - - }, - device_types = { - {device_type_id = 0x0110, device_type_revision = 1} -- Mounted Dimmable Load Control - } - } - } -}) - -local mock_device_water_valve = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("matter-thing.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.ValveConfigurationAndControl.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 2}, - }, - device_types = { - {device_type_id = 0x0042, device_type_revision = 1} -- Water Valve - } - } - } -}) - -local mock_device_parent_client_child_server = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("matter-thing.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 7, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0103, device_type_revision = 1} -- OnOff Switch - } - }, - { - endpoint_id = 10, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "CLIENT", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0103, device_type_revision = 1} -- OnOff Switch - } - }, - } -}) - -local mock_device_parent_child_switch_types = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("matter-thing.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 7, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0104, device_type_revision = 1} -- Dimmer Switch - } - }, - { - endpoint_id = 10, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0103, device_type_revision = 1} -- OnOff Switch - } - }, - } -}) - -local mock_device_parent_child_different_types = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("switch-binary.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 7, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0103, device_type_revision = 1} -- OnOff Switch - } - }, - { - endpoint_id = 10, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - }, - device_types = { - {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light - } - } - } -}) - -local mock_device_parent_child_unsupported_device_type = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("matter-thing.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 7, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - }, - device_types = { - {device_type_id = 0x0103, device_type_revision = 1} -- OnOff Switch - } - }, - { - endpoint_id = 10, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0304, device_type_revision = 2} -- Pump Controller - } - } - } -}) - -local mock_device_light_level_motion = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("light-level-motion.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = 1, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} - }, - device_types = { - {device_type_id = 0x0101, device_type_revision = 1} -- Dimmable Light - } - }, - { - endpoint_id = 2, - clusters = { - {cluster_id = clusters.OccupancySensing.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0107, device_type_revision = 1} -- Occupancy Sensor - } - } - } -}) - -local function test_init_parent_child_switch_types() - test.mock_device.add_test_device(mock_device_parent_child_switch_types) - local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device_parent_child_switch_types) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_switch_types.id, "added" }) - test.socket.matter:__expect_send({mock_device_parent_child_switch_types.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_switch_types.id, "init" }) - test.socket.matter:__expect_send({mock_device_parent_child_switch_types.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_switch_types.id, "doConfigure" }) - test.socket.matter:__expect_send({ - mock_device_parent_child_switch_types.id, - clusters.LevelControl.attributes.Options:write(mock_device_parent_child_switch_types, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_parent_child_switch_types:expect_metadata_update({ profile = "switch-level" }) - mock_device_parent_child_switch_types:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - mock_device_parent_child_switch_types:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "switch-binary", - parent_device_id = mock_device_parent_child_switch_types.id, - parent_assigned_child_key = string.format("%d", 10) - }) -end - -local function test_init_onoff() - test.mock_device.add_test_device(mock_device_onoff) - test.socket.device_lifecycle:__queue_receive({ mock_device_onoff.id, "added" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_onoff.id, "init" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_onoff.id, "doConfigure" }) - mock_device_onoff:expect_metadata_update({ profile = "switch-binary" }) - mock_device_onoff:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_onoff_client() - test.mock_device.add_test_device(mock_device_onoff_client) - test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_client.id, "added" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_client.id, "init" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_onoff_client.id, "doConfigure" }) - mock_device_onoff_client:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_parent_client_child_server() - test.mock_device.add_test_device(mock_device_parent_client_child_server) - local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device_parent_client_child_server) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_client_child_server.id, "added" }) - test.socket.matter:__expect_send({mock_device_parent_client_child_server.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_client_child_server.id, "init" }) - test.socket.matter:__expect_send({mock_device_parent_client_child_server.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_client_child_server.id, "doConfigure" }) - mock_device_parent_client_child_server:expect_metadata_update({ profile = "switch-binary" }) - mock_device_parent_client_child_server:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_dimmer() - test.mock_device.add_test_device(mock_device_dimmer) - test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer.id, "added" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer.id, "init" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_dimmer.id, "doConfigure" }) - test.socket.matter:__expect_send({ - mock_device_dimmer.id, - clusters.LevelControl.attributes.Options:write(mock_device_dimmer, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_dimmer:expect_metadata_update({ profile = "switch-level" }) - mock_device_dimmer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_color_dimmer() - test.mock_device.add_test_device(mock_device_color_dimmer) - test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "added" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "init" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_color_dimmer.id, "doConfigure" }) - mock_device_color_dimmer:expect_metadata_update({ profile = "switch-color-level" }) - mock_device_color_dimmer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_switch_vendor_override() - test.mock_device.add_test_device(mock_device_switch_vendor_override) - local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device_switch_vendor_override) - test.socket.device_lifecycle:__queue_receive({ mock_device_switch_vendor_override.id, "added" }) - test.socket.matter:__expect_send({mock_device_switch_vendor_override.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_device_switch_vendor_override.id, "init" }) - test.socket.matter:__expect_send({mock_device_switch_vendor_override.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_device_switch_vendor_override.id, "doConfigure" }) - mock_device_switch_vendor_override:expect_metadata_update({ profile = "switch-binary" }) - mock_device_switch_vendor_override:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_mounted_on_off_control() - test.mock_device.add_test_device(mock_device_mounted_on_off_control) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_mounted_on_off_control) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_mounted_on_off_control)) - end - end - test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "added" }) - test.socket.matter:__expect_send({mock_device_mounted_on_off_control.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "init" }) - test.socket.matter:__expect_send({mock_device_mounted_on_off_control.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "doConfigure" }) - test.socket.matter:__expect_send({ - mock_device_mounted_on_off_control.id, - clusters.LevelControl.attributes.Options:write(mock_device_mounted_on_off_control, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_mounted_on_off_control:expect_metadata_update({ profile = "switch-binary" }) - mock_device_mounted_on_off_control:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_mounted_dimmable_load_control() - test.mock_device.add_test_device(mock_device_mounted_dimmable_load_control) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.LevelControl.attributes.MaxLevel, - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_mounted_dimmable_load_control) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_mounted_dimmable_load_control)) - end - end - test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "added" }) - test.socket.matter:__expect_send({mock_device_mounted_dimmable_load_control.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "init" }) - test.socket.matter:__expect_send({mock_device_mounted_dimmable_load_control.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "doConfigure" }) - test.socket.matter:__expect_send({ - mock_device_mounted_dimmable_load_control.id, - clusters.LevelControl.attributes.Options:write(mock_device_mounted_dimmable_load_control, 7, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_mounted_dimmable_load_control:expect_metadata_update({ profile = "switch-level" }) - mock_device_mounted_dimmable_load_control:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_water_valve() - test.mock_device.add_test_device(mock_device_water_valve) - test.socket.device_lifecycle:__queue_receive({ mock_device_water_valve.id, "added" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_water_valve.id, "init" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_water_valve.id, "doConfigure" }) - mock_device_water_valve:expect_metadata_update({ profile = "water-valve-level" }) - mock_device_water_valve:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -local function test_init_parent_child_different_types() - test.mock_device.add_test_device(mock_device_parent_child_different_types) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode, - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_parent_child_different_types) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_parent_child_different_types)) - end - end - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_different_types.id, "added" }) - test.socket.matter:__expect_send({mock_device_parent_child_different_types.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_different_types.id, "init" }) - test.socket.matter:__expect_send({mock_device_parent_child_different_types.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_different_types.id, "doConfigure" }) - test.socket.matter:__expect_send({ - mock_device_parent_child_different_types.id, - clusters.LevelControl.attributes.Options:write(mock_device_parent_child_different_types, 10, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - test.socket.matter:__expect_send({ - mock_device_parent_child_different_types.id, - clusters.ColorControl.attributes.Options:write(mock_device_parent_child_different_types, 10, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_parent_child_different_types:expect_metadata_update({ profile = "switch-binary" }) - mock_device_parent_child_different_types:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - mock_device_parent_child_different_types:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "light-color-level", - parent_device_id = mock_device_parent_child_different_types.id, - parent_assigned_child_key = string.format("%d", 10) - }) -end - -local function test_init_parent_child_unsupported_device_type() - test.mock_device.add_test_device(mock_device_parent_child_unsupported_device_type) - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_unsupported_device_type.id, "added" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_unsupported_device_type.id, "init" }) - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_unsupported_device_type.id, "doConfigure" }) - mock_device_parent_child_unsupported_device_type:expect_metadata_update({ profile = "switch-binary" }) - test.socket.matter:__expect_send({ - mock_device_parent_child_unsupported_device_type.id, - clusters.LevelControl.attributes.Options:write(mock_device_parent_child_unsupported_device_type, 10, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_parent_child_unsupported_device_type:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - mock_device_parent_child_unsupported_device_type:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "switch-binary", - parent_device_id = mock_device_parent_child_unsupported_device_type.id, - parent_assigned_child_key = string.format("%d", 10) - }) -end - -local function test_init_light_level_motion() - test.mock_device.add_test_device(mock_device_light_level_motion) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.OccupancySensing.attributes.Occupancy - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_light_level_motion) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_light_level_motion)) - end - end - - test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "added" }) - test.socket.matter:__expect_send({mock_device_light_level_motion.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "init" }) - test.socket.matter:__expect_send({mock_device_light_level_motion.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "doConfigure" }) - test.socket.matter:__expect_send({ - mock_device_light_level_motion.id, - clusters.LevelControl.attributes.Options:write(mock_device_light_level_motion, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) - }) - mock_device_light_level_motion:expect_metadata_update({ profile = "light-level-motion" }) - mock_device_light_level_motion:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -test.register_coroutine_test( - "Test profile change on init for onoff parent cluster as server", - function() - end, - { test_init = test_init_onoff } -) - -test.register_coroutine_test( - "Test profile change on init for dimmer parent cluster as server", - function() - end, - { test_init = test_init_dimmer } -) - -test.register_coroutine_test( - "Test profile change on init for color dimmer parent cluster as server", - function() - end, - { test_init = test_init_color_dimmer } -) - -test.register_coroutine_test( - "Test init for onoff parent cluster as client", - function() - end, - { test_init = test_init_onoff_client } -) - -test.register_coroutine_test( - "Test init for device with requiring the switch category as a vendor override", - function() - end, - { test_init = test_init_switch_vendor_override } -) - -test.register_coroutine_test( - "Test init for mounted onoff control parent cluster as server", - function() - end, - { test_init = test_init_mounted_on_off_control } -) - -test.register_coroutine_test( - "Test init for mounted dimmable load control parent cluster as server", - function() - end, - { test_init = test_init_mounted_dimmable_load_control } -) - -test.register_coroutine_test( - "Test profile change on init for water valve parent cluster as server", - function() - end, - { test_init = test_init_water_valve } -) - -test.register_coroutine_test( - "Test profile change on init for onoff parent cluster as client and onoff child as server", - function() - end, - { test_init = test_init_parent_client_child_server } -) - -test.register_coroutine_test( - "Test profile change on init for onoff device when parent and child are both server", - function() - end, - { test_init = test_init_parent_child_switch_types } -) - -test.register_coroutine_test( - "Test child device attribute subscriptions when parent device has clusters that are not a superset of child device clusters", - function() - end, - { test_init = test_init_parent_child_different_types } -) - -test.register_coroutine_test( - "Test child device attributes not subscribed to for unsupported device type for child device", - function() - end, - { test_init = test_init_parent_child_unsupported_device_type } -) - -test.register_coroutine_test( - "Test init for light with motion sensor", - function() - end, - { test_init = test_init_light_level_motion } -) - -test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua index 6129dfd116..221405a62a 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua @@ -45,23 +45,36 @@ local mock_device = test.mock_device.build_test_matter_device({ } }) -local cluster_subscribe_list = { - clusters.ValveConfigurationAndControl.attributes.CurrentState, - clusters.ValveConfigurationAndControl.attributes.CurrentLevel -} - local function test_init() - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device)) - end - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.disable_startup_messages() test.mock_device.add_test_device(mock_device) end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Device should be added with correct subscription and profile", + function() + local cluster_subscribe_list = { + clusters.ValveConfigurationAndControl.attributes.CurrentState, + clusters.ValveConfigurationAndControl.attributes.CurrentLevel + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "water-valve-level" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 15 + } +) + test.register_message_test( "Open command should send the appropriate commands", { @@ -81,6 +94,9 @@ test.register_message_test( clusters.ValveConfigurationAndControl.server.commands.Open(mock_device, 1) } } + }, + { + min_api_version = 15 } ) @@ -103,6 +119,9 @@ test.register_message_test( clusters.ValveConfigurationAndControl.server.commands.Close(mock_device, 1) } } + }, + { + min_api_version = 15 } ) @@ -124,13 +143,7 @@ test.register_message_test( mock_device.id, clusters.ValveConfigurationAndControl.server.commands.Open(mock_device, 1, nil, 25) } - } - } -) - -test.register_message_test( - "Set level command should send the appropriate commands", - { + }, { channel = "capability", direction = "receive", @@ -147,6 +160,9 @@ test.register_message_test( clusters.ValveConfigurationAndControl.server.commands.Close(mock_device, 1) } } + }, + { + min_api_version = 15 } ) @@ -166,12 +182,6 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) }, - } -) - -test.register_message_test( - "Current state reports should generate appropriate events", - { { channel = "matter", direction = "receive", @@ -185,12 +195,6 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) }, - } -) - -test.register_message_test( - "Current state reports should generate appropriate events", - { { channel = "matter", direction = "receive", @@ -204,6 +208,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) }, + }, + { + min_api_version = 15 } ) @@ -223,6 +230,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.level.level(50)) }, + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua index 75e88b5fce..0b68428669 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua @@ -145,6 +145,8 @@ local function test_init_mock_3switch() } test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_3switch) + + -- the following subscribe is due to the init event sent by the test framework. test.socket.matter:__expect_send({mock_3switch.id, subscribe_request}) test.mock_device.add_test_device(mock_3switch) end @@ -157,6 +159,7 @@ local function test_init_mock_2switch() } test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_2switch) + test.socket.matter:__expect_send({mock_2switch.id, subscribe_request}) test.mock_device.add_test_device(mock_2switch) end @@ -169,6 +172,7 @@ local function test_init_mock_3switch_non_sequential() } test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_3switch_non_sequential) + test.socket.matter:__expect_send({mock_3switch_non_sequential.id, subscribe_request}) test.mock_device.add_test_device(mock_3switch_non_sequential) end @@ -194,7 +198,10 @@ test.register_message_test( } } }, - { test_init = test_init_mock_3switch } + { + test_init = test_init_mock_3switch, + min_api_version = 14 + } ) -- The custom "test_init" function also checks that the appropriate profile is switched on init @@ -218,7 +225,10 @@ test.register_message_test( } } }, - { test_init = test_init_mock_2switch } + { + test_init = test_init_mock_2switch, + min_api_version = 14 + } ) -- The custom "test_init" function also checks that the appropriate profile is switched on init @@ -242,7 +252,11 @@ test.register_message_test( } } }, - { test_init = test_init_mock_3switch_non_sequential } + { + test_init = test_init_mock_3switch_non_sequential, + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua deleted file mode 100644 index e218371df4..0000000000 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua +++ /dev/null @@ -1,706 +0,0 @@ --- Copyright © 2024 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local capabilities = require "st.capabilities" -local clusters = require "st.matter.clusters" - -test.disable_startup_messages() - -local TRANSITION_TIME = 0 -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 - -local parent_ep = 10 -local child1_ep = 20 -local child2_ep = 30 - -local mock_device = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - matter_version = { - hardware = 1, - software = 1, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = parent_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light - } - }, - { - endpoint_id = child1_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2}, -- On/Off Light - {device_type_id = 0x0101, device_type_revision = 2} -- Dimmable Light - } - }, - { - endpoint_id = child2_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - }, - device_types = { - {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light - } - }, - } -}) - -local child1_ep_non_sequential = 50 -local child2_ep_non_sequential = 30 -local child3_ep_non_sequential = 40 - -local mock_device_parent_child_endpoints_non_sequential = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), - manufacturer_info = { - vendor_id = 0x1321, - product_id = 0x000C, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = parent_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light - } - }, - { - endpoint_id = child1_ep_non_sequential, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2}, -- On/Off Light - {device_type_id = 0x0101, device_type_revision = 2} -- Dimmable Light - } - }, - { - endpoint_id = child2_ep_non_sequential, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - }, - device_types = { - {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light - } - }, - { - endpoint_id = child3_ep_non_sequential, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x010A, device_type_revision = 2} -- On/Off Plug - } - }, - } -}) - -local child_profiles = { - [child1_ep] = t_utils.get_profile_definition("light-level.yml"), - [child2_ep] = t_utils.get_profile_definition("light-color-level.yml"), -} - -local mock_children = {} -for i, endpoint in ipairs(mock_device.endpoints) do - if endpoint.endpoint_id ~= parent_ep and endpoint.endpoint_id ~= 0 then - local child_data = { - profile = child_profiles[endpoint.endpoint_id], - device_network_id = string.format("%s:%d", mock_device.id, endpoint.endpoint_id), - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) - } - mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) - end -end - -local function test_init() - test.mock_device.add_test_device(mock_device) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode, - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device)) - end - end - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, child1_ep, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, child2_ep, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, child2_ep, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - mock_device:expect_metadata_update({ profile = "light-binary" }) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - for _, child in pairs(mock_children) do - test.mock_device.add_test_device(child) - end - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "light-level", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", child1_ep) - }) - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 3", - profile = "light-color-level", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", child2_ep) - }) -end - -local child_profiles_non_sequential = { - [child1_ep_non_sequential] = t_utils.get_profile_definition("light-level.yml"), - [child2_ep_non_sequential] = t_utils.get_profile_definition("light-color-level.yml"), - [child3_ep_non_sequential] = t_utils.get_profile_definition("light-color-level.yml"), -} - -local mock_children_non_sequential = {} -for i, endpoint in ipairs(mock_device_parent_child_endpoints_non_sequential.endpoints) do - if endpoint.endpoint_id ~= parent_ep and endpoint.endpoint_id ~= 0 then - local child_data = { - profile = child_profiles_non_sequential[endpoint.endpoint_id], - device_network_id = string.format("%s:%d", mock_device_parent_child_endpoints_non_sequential.id, endpoint.endpoint_id), - parent_device_id = mock_device_parent_child_endpoints_non_sequential.id, - parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) - } - mock_children_non_sequential[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) - end -end - -local function test_init_parent_child_endpoints_non_sequential() - local unsup_mock_device = mock_device_parent_child_endpoints_non_sequential - - test.mock_device.add_test_device(unsup_mock_device) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(unsup_mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(unsup_mock_device)) - end - end - - test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "added" }) - test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "init" }) - test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "doConfigure" }) - test.socket.matter:__expect_send({unsup_mock_device.id, clusters.LevelControl.attributes.Options:write(unsup_mock_device, child1_ep_non_sequential, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - test.socket.matter:__expect_send({unsup_mock_device.id, clusters.LevelControl.attributes.Options:write(unsup_mock_device, child2_ep_non_sequential, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - test.socket.matter:__expect_send({unsup_mock_device.id, clusters.ColorControl.attributes.Options:write(unsup_mock_device, child2_ep_non_sequential, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - - unsup_mock_device:expect_metadata_update({ profile = "light-binary" }) - unsup_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - for _, child in pairs(mock_children_non_sequential) do - test.mock_device.add_test_device(child) - end - - unsup_mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "light-color-level", - parent_device_id = unsup_mock_device.id, - parent_assigned_child_key = string.format("%d", child2_ep_non_sequential) - }) - - -- switch-binary will be selected as an overridden child device profile - unsup_mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 3", - profile = "switch-binary", - parent_device_id = unsup_mock_device.id, - parent_assigned_child_key = string.format("%d", child3_ep_non_sequential) - }) - - unsup_mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 4", - profile = "light-level", - parent_device_id = unsup_mock_device.id, - parent_assigned_child_key = string.format("%d", child1_ep_non_sequential) - }) -end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Parent device: switch capability should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, parent_ep) - }, - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, parent_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } - } - }, - } -) - -test.register_message_test( - "First child device: switch capability switch should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child1_ep].id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_children[child1_ep].id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, child1_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, child1_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switch.switch.on()) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } - } - }, - } -) - -test.register_message_test( - "Second child device: switch capability should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_children[child2_ep].id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, child2_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.switch.switch.on()) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } - } - }, - } -) - -test.register_message_test( - "Current level reports should generate appropriate events", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, child1_ep, 50) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switchLevel.level(math.floor((50 / 254.0 * 100) + 0.5))) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } - } - }, - } -) - -test.register_message_test( - "Set color temperature should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, child2_ep, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, child2_ep, 556) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, - } -) - -test.register_message_test( - "X and Y color values should report hue and saturation once both have been received", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, child2_ep, 15091) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, child2_ep, 21547) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.saturation(72)) - } - } -) - -test.register_message_test( - "Set color command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColor(mock_device, child2_ep, 15182, 21547, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, child2_ep, 15091) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, child2_ep, 21547) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.saturation(72)) - } - } -) - -test.register_message_test( - "Min and max level attributes set capability constraint for child devices", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, child1_ep, 1) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, child1_ep, 254) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 1, maximum = 100})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, child2_ep, 127) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, child2_ep, 203) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 50, maximum = 80})) - } - } -) - -test.register_message_test( - "Min and max color temp attributes set capability constraint for child devices", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, child2_ep, 153) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, child2_ep, 555) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) - } - } -) - -test.register_coroutine_test( - "Test child devices are created in order of their endpoints", - function() - end, - { test_init = test_init_parent_child_endpoints_non_sequential } -) - -test.register_coroutine_test( - "Test info changed event with matter_version update", - function() - test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } })) -- bump to 2 - mock_children[child1_ep]:expect_metadata_update({ profile = "light-level" }) - mock_children[child2_ep]:expect_metadata_update({ profile = "light-color-level" }) - mock_device:expect_metadata_update({ profile = "light-binary" }) - end -) - -test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua deleted file mode 100644 index 9ac3642a63..0000000000 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua +++ /dev/null @@ -1,689 +0,0 @@ --- Copyright © 2023 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local capabilities = require "st.capabilities" -local clusters = require "st.matter.clusters" - -test.disable_startup_messages() - -local TRANSITION_TIME = 0 -local OPTIONS_MASK = 0x01 -local OPTIONS_OVERRIDE = 0x01 - -local parent_ep = 10 -local child1_ep = 20 -local child2_ep = 30 - -local mock_device = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), - manufacturer_info = { - vendor_id = 0x0000, - product_id = 0x0000, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = parent_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light - } - }, - { - endpoint_id = child1_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2}, -- On/Off Light - {device_type_id = 0x0101, device_type_revision = 2} -- Dimmable Light - } - }, - { - endpoint_id = child2_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - }, - device_types = { - {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light - } - }, - } -}) - -local child1_ep_non_sequential = 50 -local child2_ep_non_sequential = 30 -local child3_ep_non_sequential = 40 - -local mock_device_parent_child_endpoints_non_sequential = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), - manufacturer_info = { - vendor_id = 0x1321, - product_id = 0x000C, - }, - endpoints = { - { - endpoint_id = 0, - clusters = { - {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0016, device_type_revision = 1} -- RootNode - } - }, - { - endpoint_id = parent_ep, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2} -- On/Off Light - } - }, - { - endpoint_id = child1_ep_non_sequential, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2} - }, - device_types = { - {device_type_id = 0x0100, device_type_revision = 2}, -- On/Off Light - {device_type_id = 0x0101, device_type_revision = 2} -- Dimmable Light - } - }, - { - endpoint_id = child2_ep_non_sequential, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, - }, - device_types = { - {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light - } - }, - { - endpoint_id = child3_ep_non_sequential, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - }, - device_types = { - {device_type_id = 0x010A, device_type_revision = 2} -- On/Off Plug - } - }, - } -}) - -local child_profiles = { - [child1_ep] = t_utils.get_profile_definition("light-level.yml"), - [child2_ep] = t_utils.get_profile_definition("light-color-level.yml"), -} - -local mock_children = {} -for i, endpoint in ipairs(mock_device.endpoints) do - if endpoint.endpoint_id ~= parent_ep and endpoint.endpoint_id ~= 0 then - local child_data = { - profile = child_profiles[endpoint.endpoint_id], - device_network_id = string.format("%s:%d", mock_device.id, endpoint.endpoint_id), - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) - } - mock_children[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) - end -end - -local function test_init() - test.mock_device.add_test_device(mock_device) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode, - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device)) - end - end - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, child1_ep, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, child2_ep, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, child2_ep, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - mock_device:expect_metadata_update({ profile = "light-binary" }) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - for _, child in pairs(mock_children) do - test.mock_device.add_test_device(child) - end - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "light-level", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", child1_ep) - }) - - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 3", - profile = "light-color-level", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", child2_ep) - }) -end - -local child_profiles_non_sequential = { - [child1_ep_non_sequential] = t_utils.get_profile_definition("light-level.yml"), - [child2_ep_non_sequential] = t_utils.get_profile_definition("light-color-level.yml"), - [child3_ep_non_sequential] = t_utils.get_profile_definition("light-color-level.yml"), -} - -local mock_children_non_sequential = {} -for i, endpoint in ipairs(mock_device_parent_child_endpoints_non_sequential.endpoints) do - if endpoint.endpoint_id ~= parent_ep and endpoint.endpoint_id ~= 0 then - local child_data = { - profile = child_profiles_non_sequential[endpoint.endpoint_id], - device_network_id = string.format("%s:%d", mock_device_parent_child_endpoints_non_sequential.id, endpoint.endpoint_id), - parent_device_id = mock_device_parent_child_endpoints_non_sequential.id, - parent_assigned_child_key = string.format("%d", endpoint.endpoint_id) - } - mock_children_non_sequential[endpoint.endpoint_id] = test.mock_device.build_test_child_device(child_data) - end -end - -local function test_init_parent_child_endpoints_non_sequential() - test.mock_device.add_test_device(mock_device_parent_child_endpoints_non_sequential) - local cluster_subscribe_list = { - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode, - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_parent_child_endpoints_non_sequential) - for i, cluster in ipairs(cluster_subscribe_list) do - if i > 1 then - subscribe_request:merge(cluster:subscribe(mock_device_parent_child_endpoints_non_sequential)) - end - end - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_endpoints_non_sequential.id, "added" }) - test.socket.matter:__expect_send({mock_device_parent_child_endpoints_non_sequential.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_endpoints_non_sequential.id, "init" }) - test.socket.matter:__expect_send({mock_device_parent_child_endpoints_non_sequential.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_endpoints_non_sequential.id, "doConfigure" }) - test.socket.matter:__expect_send({mock_device_parent_child_endpoints_non_sequential.id, clusters.LevelControl.attributes.Options:write(mock_device_parent_child_endpoints_non_sequential, child1_ep_non_sequential, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - test.socket.matter:__expect_send({mock_device_parent_child_endpoints_non_sequential.id, clusters.LevelControl.attributes.Options:write(mock_device_parent_child_endpoints_non_sequential, child2_ep_non_sequential, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - test.socket.matter:__expect_send({mock_device_parent_child_endpoints_non_sequential.id, clusters.ColorControl.attributes.Options:write(mock_device_parent_child_endpoints_non_sequential, child2_ep_non_sequential, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - mock_device_parent_child_endpoints_non_sequential:expect_metadata_update({ profile = "light-binary" }) - mock_device_parent_child_endpoints_non_sequential:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - - for _, child in pairs(mock_children_non_sequential) do - test.mock_device.add_test_device(child) - end - - mock_device_parent_child_endpoints_non_sequential:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 2", - profile = "light-color-level", - parent_device_id = mock_device_parent_child_endpoints_non_sequential.id, - parent_assigned_child_key = string.format("%d", child2_ep_non_sequential) - }) - - -- switch-binary will be selected as an overridden child device profile - mock_device_parent_child_endpoints_non_sequential:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 3", - profile = "switch-binary", - parent_device_id = mock_device_parent_child_endpoints_non_sequential.id, - parent_assigned_child_key = string.format("%d", child3_ep_non_sequential) - }) - - mock_device_parent_child_endpoints_non_sequential:expect_device_create({ - type = "EDGE_CHILD", - label = "Matter Switch 4", - profile = "light-level", - parent_device_id = mock_device_parent_child_endpoints_non_sequential.id, - parent_assigned_child_key = string.format("%d", child1_ep_non_sequential) - }) -end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Parent device: switch capability should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_device.id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, parent_ep) - }, - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, parent_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } - } - }, - } -) - -test.register_message_test( - "First child device: switch capability switch should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child1_ep].id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_children[child1_ep].id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, child1_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, child1_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switch.switch.on()) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } - } - }, - } -) - -test.register_message_test( - "Second child device: switch capability should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "switch", component = "main", command = "on", args = { } } - } - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_cmd_handler", - { device_uuid = mock_children[child2_ep].id, capability_id = "switch", capability_cmd_id = "on" } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.OnOff.server.commands.On(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, child2_ep, true) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.switch.switch.on()) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } - } - }, - } -) - -test.register_message_test( - "Current level reports should generate appropriate events", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.server.attributes.CurrentLevel:build_test_report_data(mock_device, child1_ep, 50) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switchLevel.level(math.floor((50 / 254.0 * 100) + 0.5))) - }, - { - channel = "devices", - direction = "send", - message = { - "register_native_capability_attr_handler", - { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } - } - }, - } -) - -test.register_message_test( - "Set color temperature should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature(mock_device, child2_ep, 556, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColorTemperature:build_test_command_response(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTemperatureMireds:build_test_report_data(mock_device, child2_ep, 556) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) - }, - } -) - -test.register_message_test( - "X and Y color values should report hue and saturation once both have been received", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, child2_ep, 15091) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, child2_ep, 21547) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.saturation(72)) - } - } -) - -test.register_message_test( - "Set color command should send the appropriate commands", - { - { - channel = "capability", - direction = "receive", - message = { - mock_children[child2_ep].id, - { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 50, saturation = 72 } } } - } - }, - { - channel = "matter", - direction = "send", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColor(mock_device, child2_ep, 15182, 21547, TRANSITION_TIME, OPTIONS_MASK, OPTIONS_OVERRIDE) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.server.commands.MoveToColor:build_test_command_response(mock_device, child2_ep) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentX:build_test_report_data(mock_device, child2_ep, 15091) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.CurrentY:build_test_report_data(mock_device, child2_ep, 21547) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.hue(50)) - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorControl.saturation(72)) - } - } -) - -test.register_message_test( - "Min and max level attributes set capability constraint for child devices", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, child1_ep, 1) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, child1_ep, 254) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child1_ep]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 1, maximum = 100})) - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MinLevel:build_test_report_data(mock_device, child2_ep, 127) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.LevelControl.attributes.MaxLevel:build_test_report_data(mock_device, child2_ep, 203) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.switchLevel.levelRange({minimum = 50, maximum = 80})) - } - } -) - -test.register_message_test( - "Min and max color temp attributes set capability constraint for child devices", - { - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_report_data(mock_device, child2_ep, 153) - } - }, - { - channel = "matter", - direction = "receive", - message = { - mock_device.id, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_report_data(mock_device, child2_ep, 555) - } - }, - { - channel = "capability", - direction = "send", - message = mock_children[child2_ep]:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 1800, maximum = 6500})) - } - } -) - -test.register_coroutine_test( - "Test child devices are created in order of their endpoints", - function() - end, - { test_init = test_init_parent_child_endpoints_non_sequential } -) - -test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/test/test_stateless_step.lua b/drivers/SmartThings/matter-switch/src/test/test_stateless_step.lua new file mode 100644 index 0000000000..eec293013b --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_stateless_step.lua @@ -0,0 +1,217 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +local mock_device_color_temp = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-level-colorTemperature.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} + }, + device_types = { + {device_type_id = 0x0100, device_type_revision = 1}, -- On/Off Light + {device_type_id = 0x010C, device_type_revision = 1} -- Color Temperature Light + } + } + } +}) + +local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.ColorControl.attributes.ColorTemperatureMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, + clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, +} + +local function test_init() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_color_temp) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_color_temp)) + end + end + test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_color_temp) +end +test.set_test_init_function(test_init) + +local fields = require "switch_utils.fields" + +test.register_message_test( + "Color Temperature Step Command Test", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.ColorControl.server.commands.StepColorTemperature(mock_device_color_temp, 1, clusters.ColorControl.types.StepModeEnum.DOWN, 60, fields.DEFAULT_STEP_TRANSITION_TIME, fields.DEFAULT_MIRED_MIN, fields.DEFAULT_MIRED_MAX, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 90 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.ColorControl.server.commands.StepColorTemperature(mock_device_color_temp, 1, clusters.ColorControl.types.StepModeEnum.DOWN, 271, fields.DEFAULT_STEP_TRANSITION_TIME, fields.DEFAULT_MIRED_MIN, fields.DEFAULT_MIRED_MAX, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { -50 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.ColorControl.server.commands.StepColorTemperature(mock_device_color_temp, 1, clusters.ColorControl.types.StepModeEnum.UP, 151, fields.DEFAULT_STEP_TRANSITION_TIME, fields.DEFAULT_MIRED_MIN, fields.DEFAULT_MIRED_MAX, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + } + }, + { + min_api_version = 14 + } +) + + +test.register_message_test( + "Level Step Command Test", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.LevelControl.server.commands.Step(mock_device_color_temp, 1, clusters.LevelControl.types.StepModeEnum.UP, 64, fields.DEFAULT_STEP_TRANSITION_TIME, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { -50 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.LevelControl.server.commands.Step(mock_device_color_temp, 1, clusters.LevelControl.types.StepModeEnum.DOWN, 127, fields.DEFAULT_STEP_TRANSITION_TIME, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device_color_temp.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 100 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device_color_temp.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "matter", + direction = "send", + message = { + mock_device_color_temp.id, + clusters.LevelControl.server.commands.Step(mock_device_color_temp, 1, clusters.LevelControl.types.StepModeEnum.UP, 254, fields.DEFAULT_STEP_TRANSITION_TIME, fields.OPTIONS_MASK, fields.IGNORE_COMMAND_IF_OFF) + }, + } + }, + { + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_third_reality_mk1.lua b/drivers/SmartThings/matter-switch/src/test/test_third_reality_mk1.lua deleted file mode 100644 index 0a72a1316e..0000000000 --- a/drivers/SmartThings/matter-switch/src/test/test_third_reality_mk1.lua +++ /dev/null @@ -1,239 +0,0 @@ --- Copyright © 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local capabilities = require "st.capabilities" -local clusters = require "st.matter.clusters" -local dkjson = require "dkjson" -local t_utils = require "integration_test.utils" -local test = require "integration_test" -local utils = require "st.utils" - -local mock_device = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("12-button-keyboard.yml"), - manufacturer_info = {vendor_id = 0x1407, product_id = 0x1388}, - endpoints = { - { - endpoint_id = 0, - clusters = { - { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, - }, - device_types = { - { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode - } - }, - { - endpoint_id = 1, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 2, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 3, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 4, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 5, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 6, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 7, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 8, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 9, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 10, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 11, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - }, - { - endpoint_id = 12, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, - cluster_type = "SERVER" - } - }, - device_types = { - {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch - } - } - } -}) - -local function configure_buttons() - for key = 1, 12 do - local component = "F" .. key - if key == 1 then component = "main" end - test.socket.capability:__expect_send(mock_device:generate_test_message(component, capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) - test.socket.capability:__expect_send(mock_device:generate_test_message(component, capabilities.button.button.pushed({state_change = false}))) - end -end - -local function test_init() - test.disable_startup_messages() - test.mock_device.add_test_device(mock_device) - local cluster_subscribe_list = { - clusters.Switch.events.InitialPress - } - local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) - for i, clus in ipairs(cluster_subscribe_list) do - if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end - end - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - mock_device:expect_metadata_update({ profile = "12-button-keyboard" }) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - configure_buttons() - - local device_info_copy = utils.deep_copy(mock_device.raw_st_data) - device_info_copy.profile.id = "12-buttons-keyboard" - local device_info_json = dkjson.encode(device_info_copy) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) - configure_buttons() - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) -end - -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "Handle single press sequence", - function() - for key = 1, 12 do - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.Switch.events.InitialPress:build_test_event_report(mock_device, key, {new_position = 1}) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message(key == 1 and "main" or "F" .. key, capabilities.button.button.pushed({state_change = true})) - ) - end - end -) - --- run the tests -test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_third_reality_subdrivers.lua b/drivers/SmartThings/matter-switch/src/test/test_third_reality_subdrivers.lua new file mode 100644 index 0000000000..8ed1a78031 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_third_reality_subdrivers.lua @@ -0,0 +1,552 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local dkjson = require "dkjson" +local utils = require "st.utils" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("garage-door-battery.yml"), + manufacturer_info = {vendor_id = 0x1407, product_id = 0x1098}, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = clusters.PowerSource.types.Feature.BATTERY, + }, + }, + device_types = { + {device_type_id = 0x010A, device_type_revision = 1} -- On/Off Plug-in Unit + } + } + } +}) + +-- The subscribe list matches the profile capabilities: +-- doorControl -> OnOff.attributes.OnOff +-- battery -> PowerSource.attributes.BatPercentRemaining +-- The subdriver overrides device_init, so the main driver's extend_device("subscribe", ...) +-- is not called; the default device:subscribe() is used, which does not add AttributeList. +local cluster_subscribe_list = { + clusters.OnOff.attributes.OnOff, + clusters.PowerSource.attributes.BatPercentRemaining, +} + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + -- added lifecycle: subdriver overrides device_added to a no-op so no subscribe here + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + + -- init lifecycle: device_init subscribes + test.socket.device_lifecycle:__queue_receive({mock_device.id, "init"}) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + -- doConfigure: sets battery support field and updates profile metadata + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({profile = "garage-door-battery"}) + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) +end + +test.set_test_init_function(test_init) + +-- ── Attribute handler tests ────────────────────────────────────────────────── + +test.register_message_test( + "OnOff true should emit door.open", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 1, true) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.doorControl.door.open()) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "OnOff false should emit door.closed", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.OnOff.attributes.OnOff:build_test_report_data(mock_device, 1, false) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.doorControl.door.closed()) + } + }, + { + min_api_version = 15 + } +) + +-- ── Capability command tests ───────────────────────────────────────────────── + +test.register_message_test( + "doorControl open command should emit opening then send OnOff.On", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "doorControl", component = "main", command = "open", args = {}} + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.doorControl.door.opening()) + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.On(mock_device, 1) + } + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "doorControl close command should emit closing then send OnOff.Off", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + {capability = "doorControl", component = "main", command = "close", args = {}} + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.doorControl.door.closing()) + }, + { + channel = "matter", + direction = "send", + message = { + mock_device.id, + clusters.OnOff.server.commands.Off(mock_device, 1) + } + } + }, + { + min_api_version = 15 + } +) + +-- ── Battery attribute tests ─────────────────────────────────────────────────── + +test.register_message_test( + "BatPercentRemaining report should emit correct battery percentage", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(mock_device, 1, 200) + } + }, + { + channel = "capability", + direction = "send", + -- BatPercentRemaining is in units of 0.5%, so 200 = 100% + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "BatPercentRemaining report of 150 should emit 75% battery", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(mock_device, 1, 150) + } + }, + { + channel = "capability", + direction = "send", + -- 150 * 0.5 = 75% + message = mock_device:generate_test_message("main", capabilities.battery.battery(75)) + } + }, + { + min_api_version = 15 + } +) + +-- ── Profile / driverSwitched tests ──────────────────────────────────────────── + +test.register_coroutine_test( + "doConfigure should set garage-door-battery profile", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + mock_device:expect_metadata_update({profile = "garage-door-battery"}) + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) + end, + {min_api_version = 15} +) + +test.register_coroutine_test( + "driverSwitched should restore garage-door-battery profile", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "driverSwitched"}) + mock_device:expect_metadata_update({profile = "garage-door-battery"}) + mock_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) + end, + {min_api_version = 15} +) + + +local mock_device_misprofiled = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("light-binary.yml"), + manufacturer_info = {vendor_id = 0x1407, product_id = 0x1098}, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.OnOff.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + { + cluster_id = clusters.PowerSource.ID, + cluster_type = "SERVER", + feature_map = clusters.PowerSource.types.Feature.BATTERY, + }, + }, + device_types = { + {device_type_id = 0x010A, device_type_revision = 1} -- On/Off Plug-in Unit + } + } + } +}) + +test.register_coroutine_test( + "doConfigure should correct profile if misprofiled", + function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_misprofiled) + + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_misprofiled) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device_misprofiled)) end + end + + -- added lifecycle: subdriver overrides device_added to a no-op so no subscribe here + test.socket.device_lifecycle:__queue_receive({mock_device_misprofiled.id, "added"}) + + -- init lifecycle: device_init subscribes + test.socket.device_lifecycle:__queue_receive({mock_device_misprofiled.id, "init"}) + test.socket.matter:__expect_send({mock_device_misprofiled.id, subscribe_request}) + + -- doConfigure: sets battery support field and updates profile metadata + test.socket.device_lifecycle:__queue_receive({mock_device_misprofiled.id, "doConfigure"}) + mock_device_misprofiled:expect_metadata_update({profile = "garage-door-battery"}) + mock_device_misprofiled:expect_metadata_update({provisioning_state = "PROVISIONED"}) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_misprofiled) end, + min_api_version = 15 + } +) + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("12-button-keyboard.yml"), + manufacturer_info = {vendor_id = 0x1407, product_id = 0x1388}, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 2, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 3, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 4, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 5, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 6, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 7, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 8, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 9, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + } + } +}) + +local function configure_buttons() + for key = 1, 12 do + local component = "F" .. key + if key == 1 then component = "main" end + test.socket.capability:__expect_send(mock_device:generate_test_message(component, capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + end +end + +local function test_init_mk1() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + local cluster_subscribe_list = { + clusters.Switch.events.InitialPress + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "12-button-keyboard" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + configure_buttons() + + local device_info_copy = utils.deep_copy(mock_device.raw_st_data) + device_info_copy.profile.id = "12-buttons-keyboard" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) + configure_buttons() + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) +end + +test.register_coroutine_test( + "Handle single press sequence", + function() + for key = 1, 12 do + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(mock_device, key, {new_position = 1}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message(key == 1 and "main" or "F" .. key, capabilities.button.button.pushed({state_change = true})) + ) + end + end, + { + test_init = test_init_mk1, + min_api_version = 15 + } +) + +-- run the tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/fingerprints.yml b/drivers/SmartThings/matter-thermostat/fingerprints.yml index 6cd56d3a24..399f3861fc 100644 --- a/drivers/SmartThings/matter-thermostat/fingerprints.yml +++ b/drivers/SmartThings/matter-thermostat/fingerprints.yml @@ -33,6 +33,11 @@ matterManufacturer: vendorId: 0x130A productId: 0x007D deviceProfileName: thermostat-heating-only-nostate + - id: "4874/127" + deviceLabel: Eve Thermostat (Europe) + vendorId: 0x130A + productId: 0x007F + deviceProfileName: thermostat-heating-only-nostate-nobattery #Habi - id: "5415/1" deviceLabel: habi Matter Wireless Room Thermostat @@ -44,6 +49,12 @@ matterManufacturer: vendorId: 0x1527 productId: 0x0002 deviceProfileName: thermostat-heating-only-batteryLevel + #Leviton + - id: "4251/4101" + deviceLabel: Decora Smart Wi-Fi (2ndGen) Fan Speed Controller + vendorId: 0x109B + productId: 0x1005 + deviceProfileName: fan-generic #Lux - id: "4614/1" deviceLabel: LUX TQ1 Smart Thermostat @@ -78,12 +89,39 @@ 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 + vendorId: 0x141F + productId: 0x1005 + deviceProfileName: thermostat-humidity #720 - id: "5482/1" deviceLabel: xCREAS Smart Air Purifier Cyclone400 vendorId: 0x156A productId: 0x0001 deviceProfileName: air-purifier-hepa-wind-aqs-pm25-meas-pm25-level + #Gerlsair + - id: "5493/1" + deviceLabel: Smart AC Controller + vendorId: 0x1575 + productId: 0x0001 + deviceProfileName: thermostat-modular matterGeneric: - id: "matter/hvac/heatcool" deviceLabel: Matter Thermostat diff --git a/drivers/SmartThings/matter-thermostat/src/init.lua b/drivers/SmartThings/matter-thermostat/src/init.lua index 6697c80fb6..d438ba0952 100644 --- a/drivers/SmartThings/matter-thermostat/src/init.lua +++ b/drivers/SmartThings/matter-thermostat/src/init.lua @@ -90,6 +90,7 @@ function ThermostatLifecycleHandlers.device_init(driver, device) device:subscribe() device:set_component_to_endpoint_fn(thermostat_utils.component_to_endpoint) device:set_endpoint_to_component_fn(thermostat_utils.endpoint_to_component) + thermostat_utils.handle_thermostat_operating_state_info(device) if not device:get_field(fields.setpoint_limit_device_field.MIN_SETPOINT_DEADBAND_CHECKED) then local auto_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.AUTOMODE}) --Query min setpoint deadband if needed @@ -100,14 +101,11 @@ function ThermostatLifecycleHandlers.device_init(driver, device) -- device energy reporting must be handled cumulatively, periodically, or by both simulatanously. -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. - local electrical_energy_measurement_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID) - if #electrical_energy_measurement_eps > 0 then - local cumulative_energy_eps = embedded_cluster_utils.get_endpoints( + if #embedded_cluster_utils.get_endpoints( device, clusters.ElectricalEnergyMeasurement.ID, {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY} - ) - if #cumulative_energy_eps == 0 then device:set_field(fields.CUMULATIVE_REPORTS_NOT_SUPPORTED, true, {persist = false}) end + ) == 0 then device:set_field(fields.CUMULATIVE_REPORTS_NOT_SUPPORTED, true, {persist = false}) end end @@ -118,7 +116,8 @@ function ThermostatLifecycleHandlers.info_changed(driver, device, event, args) device:extend_device("supports_capability_by_id", thermostat_utils.supports_capability_by_id_modular) end - if device.profile.id ~= args.old_st_store.profile.id then + if not thermostat_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) then + thermostat_utils.handle_thermostat_operating_state_info(device) device:subscribe() end end diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua index ca7fddcdd9..630896d25c 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua @@ -430,7 +430,10 @@ test.register_coroutine_test( mock_device_ap_aqs:expect_metadata_update({ profile = "air-purifier-hepa-ac-aqs-co2-tvoc-meas-co2-radon-level" }) mock_device_ap_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, - { test_init = test_init_ap_aqs } + { + test_init = test_init_ap_aqs, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -443,7 +446,10 @@ test.register_coroutine_test( mock_device_ap_thermo_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) print(mock_device_ap_thermo_aqs.profile) end, - { test_init = test_init_ap_thermo_aqs } + { + test_init = test_init_ap_thermo_aqs, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -465,7 +471,10 @@ test.register_coroutine_test( mock_device_ap_thermo_aqs_preconfigured:generate_test_message("main", capabilities.formaldehydeMeasurement.formaldehydeLevel({value = 14, unit = "ppm"})) ) end, - { test_init = test_init_ap_thermo_aqs_preconfigured } + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 15 + } ) test.register_message_test( @@ -519,6 +528,9 @@ test.register_message_test( clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.AUTO) } } + }, + { + min_api_version = 15 } ) @@ -560,6 +572,9 @@ test.register_message_test( capabilities.airPurifierFanMode.airPurifierFanMode.high.NAME }, {visibility={displayed=false}})) }, + }, + { + min_api_version = 15 } ) @@ -595,6 +610,9 @@ test.register_message_test( clusters.FanControl.attributes.PercentSetting:write(mock_device, 1, 50) } } + }, + { + min_api_version = 15 } ) @@ -640,6 +658,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.airPurifierFanMode.high()) }, + }, + { + min_api_version = 15 } ) @@ -698,6 +719,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.replace()) }, + }, + { + min_api_version = 15 } ) @@ -751,6 +775,9 @@ test.register_message_test( clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) } } + }, + { + min_api_version = 15 } ) @@ -770,6 +797,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(100)) }, + }, + { + min_api_version = 15 } ) @@ -838,6 +868,9 @@ test.register_message_test( clusters.FanControl.attributes.RockSetting:write(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_UP_DOWN) } } + }, + { + min_api_version = 15 } ) @@ -853,7 +886,10 @@ test.register_coroutine_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device_ap_thermo_aqs_preconfigured, 7, 2100) }) end, - { test_init = test_init_ap_thermo_aqs_preconfigured } + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -876,7 +912,10 @@ test.register_coroutine_test( clusters.ActivatedCarbonFilterMonitoring.server.commands.ResetCondition(mock_device_ap_thermo_aqs_preconfigured, 1) }) end, - { test_init = test_init_ap_thermo_aqs_preconfigured } + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_api9.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_api9.lua index fd68c958d9..4fed28f10f 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_api9.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_api9.lua @@ -6,9 +6,6 @@ local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" local SinglePrecisionFloat = require "st.matter.data_types.SinglePrecisionFloat" local t_utils = require "integration_test.utils" -local version = require "version" - -version.api = 9 -- include driver-side cluster definitions to test embedded clusters on lower api versions clusters.HepaFilterMonitoring = require "embedded_clusters.HepaFilterMonitoring" @@ -430,7 +427,11 @@ test.register_coroutine_test( mock_device_ap_aqs:expect_metadata_update({ profile = "air-purifier-hepa-ac-aqs-co2-tvoc-meas-co2-radon-level" }) mock_device_ap_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, - { test_init = test_init_ap_aqs } + { + test_init = test_init_ap_aqs, + min_api_version = 9, + max_api_version = 10 + } ) test.register_coroutine_test( @@ -443,7 +444,11 @@ test.register_coroutine_test( mock_device_ap_thermo_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) print(mock_device_ap_thermo_aqs.profile) end, - { test_init = test_init_ap_thermo_aqs } + { + test_init = test_init_ap_thermo_aqs, + min_api_version = 9, + max_api_version = 10 + } ) test.register_coroutine_test( @@ -465,7 +470,11 @@ test.register_coroutine_test( mock_device_ap_thermo_aqs_preconfigured:generate_test_message("main", capabilities.formaldehydeMeasurement.formaldehydeLevel({value = 14, unit = "ppm"})) ) end, - { test_init = test_init_ap_thermo_aqs_preconfigured } + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 9, + max_api_version = 10 + } ) test.register_message_test( @@ -519,6 +528,10 @@ test.register_message_test( clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.AUTO) } } + }, + { + min_api_version = 9, + max_api_version = 10 } ) @@ -560,6 +573,10 @@ test.register_message_test( capabilities.airPurifierFanMode.airPurifierFanMode.high.NAME }, {visibility={displayed=false}})) }, + }, + { + min_api_version = 9, + max_api_version = 10 } ) @@ -595,6 +612,10 @@ test.register_message_test( clusters.FanControl.attributes.PercentSetting:write(mock_device, 1, 50) } } + }, + { + min_api_version = 9, + max_api_version = 10 } ) @@ -640,6 +661,10 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.airPurifierFanMode.airPurifierFanMode.high()) } + }, + { + min_api_version = 9, + max_api_version = 10 } ) @@ -698,6 +723,10 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("activatedCarbonFilter", capabilities.filterStatus.filterStatus.replace()) } + }, + { + min_api_version = 9, + max_api_version = 10 } ) @@ -752,6 +781,10 @@ test.register_message_test( clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) } } + }, + { + min_api_version = 9, + max_api_version = 10 } ) @@ -771,6 +804,10 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.fanSpeedPercent.percent(100)) } + }, + { + min_api_version = 9, + max_api_version = 10 } ) @@ -840,6 +877,10 @@ test.register_message_test( clusters.FanControl.attributes.RockSetting:write(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_UP_DOWN) } } + }, + { + min_api_version = 9, + max_api_version = 10 } ) @@ -855,7 +896,11 @@ test.register_coroutine_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device_ap_thermo_aqs_preconfigured, 7, 2100) }) end, - { test_init = test_init_ap_thermo_aqs_preconfigured } + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 9, + max_api_version = 10 + } ) test.register_coroutine_test( @@ -878,7 +923,11 @@ test.register_coroutine_test( clusters.ActivatedCarbonFilterMonitoring.server.commands.ResetCondition(mock_device_ap_thermo_aqs_preconfigured, 1) }) end, - { test_init = test_init_ap_thermo_aqs_preconfigured } + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 9, + max_api_version = 10 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua index 745076cd04..fcc8740458 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua @@ -315,7 +315,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive(mock_device_basic:generate_info_changed({ profile = updated_device_profile })) test.socket.matter:__expect_send({mock_device_basic.id, subscribe_request}) end, - { test_init = test_init_basic } + { + test_init = test_init_basic, + min_api_version = 15 + } ) local expected_update_metadata= { @@ -389,7 +392,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive(mock_device_ap_thermo_aqs:generate_info_changed({ profile = updated_device_profile })) test.socket.matter:__expect_send({mock_device_ap_thermo_aqs.id, subscribe_request}) end, - { test_init = test_init_ap_thermo_aqs_preconfigured } + { + test_init = test_init_ap_thermo_aqs_preconfigured, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_fan.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_fan.lua index 8eefceb23b..8d8964acf0 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_fan.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_fan.lua @@ -111,7 +111,10 @@ test.register_coroutine_test( mock_device:expect_metadata_update({ profile = "fan-rock-wind" }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, - { test_init = test_init } + { + test_init = test_init, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -123,7 +126,10 @@ test.register_coroutine_test( mock_device_generic:expect_metadata_update({ profile = "fan-generic" }) mock_device_generic:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end, - { test_init = test_init_generic } + { + test_init = test_init_generic, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_heat_pump.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_heat_pump.lua index e96bfb69fc..730a5db48a 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_heat_pump.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_heat_pump.lua @@ -146,7 +146,10 @@ test.register_coroutine_test( local component_to_endpoint_map = mock_device:get_field("__component_to_endpoint_map") assert(component_to_endpoint_map["thermostatOne"] == THERMOSTAT_ONE_EP, string.format("Thermostat One Endpoint must be %d", THERMOSTAT_ONE_EP)) assert(component_to_endpoint_map["thermostatTwo"] == THERMOSTAT_TWO_EP, string.format("Thermostat Two Endpoint must be %d", THERMOSTAT_TWO_EP)) - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -188,6 +191,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 23.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -230,6 +236,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 19.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -268,6 +277,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, THERMOSTAT_TWO_EP, 25*100) } } + }, + { + min_api_version = 15 } ) @@ -306,6 +318,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, THERMOSTAT_TWO_EP , 13*100) } } + }, + { + min_api_version = 15 } ) @@ -370,6 +385,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.thermostatMode.heat()) }, + }, + { + min_api_version = 15 } ) @@ -455,6 +473,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.supportedThermostatModes({"off", "cool"}, {visibility={displayed=false}})) }, + }, + { + min_api_version = 15 } ) @@ -523,6 +544,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("thermostatTwo", capabilities.thermostatMode.thermostatMode.emergency_heat()) }, + }, + { + min_api_version = 15 } ) @@ -587,7 +611,10 @@ test.register_message_test( message = mock_device_with_auto:generate_test_message("thermostatTwo", capabilities.thermostatMode.thermostatMode.emergency_heat()) }, }, - { test_init = test_init_auto } + { + test_init = test_init_auto, + min_api_version = 15 + } ) test.register_message_test( @@ -608,6 +635,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 15.0, unit = "W" })) } + }, + { + min_api_version = 15 } ) @@ -630,6 +660,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 15, unit = "Wh" })) } + }, + { + min_api_version = 15 } ) @@ -676,7 +709,8 @@ test.register_coroutine_test( { test_init = function() test_init() - end + end, + min_api_version = 15 } ) @@ -728,7 +762,8 @@ test.register_coroutine_test( { test_init = function() test_init() - end + end, + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac.lua index 35728e628d..012fc0ab66 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac.lua @@ -21,7 +21,7 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode } }, { @@ -32,6 +32,9 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 0}, {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0072, device_type_revision = 1 } -- Room Air Conditioner } } } @@ -153,6 +156,9 @@ local function test_init() end end end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.mock_device.add_test_device(mock_device) end @@ -211,6 +217,9 @@ local function test_init_configure() end end end + test.socket.capability:__expect_send( + mock_device_configure:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device_configure.id, subscribe_request}) local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() @@ -285,7 +294,10 @@ test.register_coroutine_test( ) mock_device_configure:expect_metadata_update({ profile = "room-air-conditioner" }) end, - { test_init = test_init_configure } + { + test_init = test_init_configure, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -303,7 +315,10 @@ test.register_coroutine_test( ) mock_device_nostate:expect_metadata_update({ profile = "room-air-conditioner-fan-heating-cooling-nostate" }) end, - { test_init = test_init_nostate } + { + test_init = test_init_nostate, + min_api_version = 15 + } ) test.register_message_test( @@ -338,6 +353,9 @@ test.register_message_test( clusters.FanControl.attributes.PercentSetting:write(mock_device, 1, 50) } } + }, + { + min_api_version = 15 } ) @@ -391,6 +409,9 @@ test.register_message_test( clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND) } } + }, + { + min_api_version = 15 } ) @@ -436,6 +457,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.airConditionerFanMode.fanMode("high")) } + }, + { + min_api_version = 15 } ) @@ -469,7 +493,100 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.airConditionerFanMode.supportedAcFanModes({"off", "low", "medium", "high", "auto"}, {visibility={displayed=false}})) } + }, + { + min_api_version = 15 } ) +local ControlSequenceOfOperation = clusters.Thermostat.attributes.ControlSequenceOfOperation +test.register_message_test( + "Room AC control sequence reports should generate correct messages", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, 1, ControlSequenceOfOperation.COOLING_AND_HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, 1, ControlSequenceOfOperation.HEATING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + ControlSequenceOfOperation:build_test_report_data(mock_device, 1, ControlSequenceOfOperation.COOLING_WITH_REHEAT) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"cool"}, {visibility={displayed=false}})) + }, + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Additional mode reports should extend the supported modes", + { + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.ControlSequenceOfOperation:build_test_report_data(mock_device, 1, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"cool", "heat"}, {visibility={displayed=false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_device.id, + clusters.Thermostat.server.attributes.SystemMode:build_test_report_data(mock_device, 1, 5) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"cool", "heat", "emergency heat"}, {visibility={displayed=false}})) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) + } + }, + { + min_api_version = 15 + } +) + + test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua index 96577d72a3..fe15fdb026 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua @@ -87,6 +87,9 @@ local function initialize_mock_device(generic_mock_device, generic_subscribed_at end end end + test.socket.capability:__expect_send( + generic_mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) return subscribe_request end @@ -309,7 +312,10 @@ test.register_coroutine_test( function() test_room_ac_device_type_update_modular_profile(mock_device_basic, expected_metadata_basic, subscribe_request_basic, uint32(0x29)) end, - { test_init = test_init_basic } + { + test_init = test_init_basic, + min_api_version = 15 + } ) local expected_metadata_no_state = { @@ -334,6 +340,9 @@ test.register_coroutine_test( function() test_room_ac_device_type_update_modular_profile(mock_device_no_state, expected_metadata_no_state, subscribe_request_no_state, uint32(0)) end, - { test_init = test_init_no_state } + { + test_init = test_init_no_state, + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_battery.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_battery.lua index 9fb8536c23..0206d949f5 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_battery.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_battery.lua @@ -4,6 +4,7 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" local uint32 = require "st.matter.data_types.Uint32" test.set_rpc_version(7) @@ -69,6 +70,9 @@ local function test_init() end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "cooling"}, {visibility = {displayed = false}})) + ) test.mock_device.add_test_device(mock_device) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) @@ -101,7 +105,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ profile = "thermostat-cooling-only-nostate" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -120,7 +127,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ profile = "thermostat-cooling-only-nostate-batteryLevel" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -132,7 +142,10 @@ test.register_coroutine_test( clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 1, {uint32(10)}) } ) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua index c898fab362..efdeeec6fe 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua @@ -9,7 +9,7 @@ local clusters = require "st.matter.clusters" test.set_rpc_version(7) local mock_device = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + profile = t_utils.get_profile_definition("thermostat-humidity-fan-nostate.yml"), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -46,7 +46,7 @@ local mock_device = test.mock_device.build_test_matter_device({ }) local mock_device_simple = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("thermostat.yml"), + profile = t_utils.get_profile_definition("thermostat-nostate.yml"), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -81,7 +81,7 @@ local mock_device_simple = test.mock_device.build_test_matter_device({ }) local mock_device_no_battery = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("thermostat.yml"), + profile = t_utils.get_profile_definition("thermostat-nostate.yml"), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -123,7 +123,6 @@ local cluster_subscribe_list = { clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, - clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, clusters.TemperatureMeasurement.attributes.MeasuredValue, clusters.TemperatureMeasurement.attributes.MinMeasuredValue, @@ -142,7 +141,6 @@ local cluster_subscribe_list_simple = { clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, - clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, clusters.TemperatureMeasurement.attributes.MeasuredValue, clusters.TemperatureMeasurement.attributes.MinMeasuredValue, @@ -158,7 +156,6 @@ local cluster_subscribe_list_no_battery = { clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.attributes.SystemMode, - clusters.Thermostat.attributes.ThermostatRunningState, clusters.Thermostat.attributes.ControlSequenceOfOperation, clusters.TemperatureMeasurement.attributes.MeasuredValue, clusters.TemperatureMeasurement.attributes.MinMeasuredValue, @@ -229,7 +226,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ profile = "thermostat-humidity-fan-heating-only" }) -end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -250,7 +250,10 @@ test.register_coroutine_test( } } test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) -end +end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -272,7 +275,10 @@ test.register_coroutine_test( } ) mock_device_simple:expect_metadata_update({ profile = "thermostat-cooling-only-nostate" }) -end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -286,7 +292,10 @@ test.register_coroutine_test( } ) mock_device_no_battery:expect_metadata_update({ profile = "thermostat-cooling-only-nostate-nobattery" }) -end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua index 2660f75b6e..ac72e56018 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_multiple_device_types.lua @@ -149,6 +149,9 @@ local function test_init() test.socket.matter:__expect_send({mock_device.id, read_req}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device.id, get_subscribe_request(mock_device, cluster_subscribe_list)}) end test.set_test_init_function(test_init) @@ -167,6 +170,9 @@ local function test_init_disorder_endpoints() test.socket.matter:__expect_send({mock_device_disorder_endpoints.id, read_req}) test.socket.device_lifecycle:__queue_receive({ mock_device_disorder_endpoints.id, "init" }) + test.socket.capability:__expect_send( + mock_device_disorder_endpoints:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device_disorder_endpoints.id, get_subscribe_request( mock_device_disorder_endpoints, cluster_subscribe_list)}) end @@ -232,7 +238,10 @@ test.register_coroutine_test( function() test_thermostat_device_type_update_modular_profile(mock_device, expected_metadata, get_subscribe_request(mock_device, new_cluster_subscribe_list)) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -241,7 +250,10 @@ test.register_coroutine_test( test_thermostat_device_type_update_modular_profile(mock_device_disorder_endpoints, expected_metadata, get_subscribe_request(mock_device_disorder_endpoints, new_cluster_subscribe_list)) end, - { test_init = test_init_disorder_endpoints } + { + test_init = test_init_disorder_endpoints, + min_api_version = 15 + } ) test.register_coroutine_test( @@ -272,7 +284,10 @@ test.register_coroutine_test( mock_device.id, clusters.FanControl.attributes.PercentSetting:write(mock_device, 2, 50) }) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua index ae9c25940f..ae5dcc10af 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits.lua @@ -65,6 +65,9 @@ local function test_init() subscribe_request:merge(cluster:subscribe(mock_device)) end end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() @@ -145,7 +148,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_heating_setpoint) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -171,7 +177,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_cooling_setpoint) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -197,7 +206,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_heating_setpoint) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -223,7 +235,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_cooling_setpoint) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -242,7 +257,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_heating_setpoint) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -261,7 +279,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", cached_cooling_setpoint) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -274,7 +295,10 @@ test.register_coroutine_test( test.wait_for_events() local min_setpoint_deadband_checked = mock_device:get_field("MIN_SETPOINT_DEADBAND_CHECKED") assert(min_setpoint_deadband_checked == true, "min_setpoint_deadband_checked is True") - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -301,6 +325,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.1 }, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -328,6 +355,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 10.00, maximum = 32.22, step = 0.1 }, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -355,6 +385,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 5.00, maximum = 39.00 }, unit = "C" })) } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits_rpc.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits_rpc.lua index bbd084cb72..13702d015d 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits_rpc.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_setpoint_limits_rpc.lua @@ -4,6 +4,7 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" local mock_device = test.mock_device.build_test_matter_device({ @@ -33,6 +34,9 @@ local mock_device = test.mock_device.build_test_matter_device({ }, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY}, {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "BOTH"}, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat } } } @@ -62,6 +66,9 @@ local function test_init() subscribe_request:merge(cluster:subscribe(mock_device)) end end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() @@ -92,6 +99,9 @@ test.register_message_test( clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit:build_test_report_data(mock_device, 1, 3222) } } + }, + { + min_api_version = 15 } ) @@ -114,6 +124,9 @@ test.register_message_test( clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit:build_test_report_data(mock_device, 1, 3222) } } + }, + { + min_api_version = 15 } ) @@ -136,7 +149,11 @@ test.register_message_test( clusters.TemperatureMeasurement.attributes.MaxMeasuredValue:build_test_report_data(mock_device, 1, 4000) } } + }, + { + min_api_version = 15 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua index e40572ff5d..c7ab7d646f 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat.lua @@ -20,7 +20,7 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, }, device_types = { - device_type_id = 0x0016, device_type_revision = 1, -- RootNode + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode } }, { @@ -36,6 +36,9 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat } } } @@ -70,6 +73,9 @@ local mock_device_auto = test.mock_device.build_test_matter_device({ {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat } } } @@ -103,7 +109,10 @@ local function test_init() end end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) + test.mock_device.add_test_device(mock_device) end test.set_test_init_function(test_init) @@ -135,6 +144,9 @@ local function test_init_auto() end end test.socket.matter:__expect_send({mock_device_auto.id, subscribe_request}) + test.socket.capability:__expect_send( + mock_device_auto:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device_auto.id, clusters.Thermostat.attributes.MinSetpointDeadBand:read(mock_device_auto)}) test.mock_device.add_test_device(mock_device_auto) end @@ -155,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) } + }, + { + min_api_version = 15 } ) @@ -174,6 +189,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -193,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -217,6 +238,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -241,6 +265,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -260,6 +287,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.cooling()) } + }, + { + min_api_version = 15 } ) @@ -279,6 +309,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) } + }, + { + min_api_version = 15 } ) @@ -298,6 +331,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.fan_only()) } + }, + { + min_api_version = 15 } ) @@ -317,6 +353,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.idle()) } + }, + { + min_api_version = 15 } ) @@ -351,6 +390,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) }, + }, + { + min_api_version = 15 } ) @@ -397,6 +439,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool"}, {visibility={displayed=false}})) }, + }, + { + min_api_version = 15 } ) @@ -443,7 +488,10 @@ test.register_message_test( message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"off", "cool", "auto"}, {visibility={displayed=false}})) }, }, - { test_init = test_init_auto } + { + test_init = test_init_auto, + min_api_version = 15 + } ) test.register_message_test( @@ -480,6 +528,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) } + }, + { + min_api_version = 15 } ) @@ -518,7 +569,10 @@ test.register_message_test( message = mock_device_auto:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) } }, - { test_init = test_init_auto } + { + test_init = test_init_auto, + min_api_version = 15 + } ) test.register_message_test( @@ -567,7 +621,10 @@ test.register_message_test( } }, }, - { test_init = test_init_auto } + { + test_init = test_init_auto, + min_api_version = 15 + } ) local FanMode = clusters.FanControl.attributes.FanMode @@ -621,6 +678,9 @@ test.register_message_test( FanMode:build_test_report_data(mock_device, 1, FanMode.OFF) } } + }, + { + min_api_version = 15 } ) @@ -654,6 +714,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.supportedThermostatFanModes({"auto", "on"}, {visibility={displayed=false}})) }, + }, + { + min_api_version = 15 } ) @@ -676,6 +739,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, 15*100) } } + }, + { + min_api_version = 15 } ) @@ -698,6 +764,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 1, 25*100) } } + }, + { + min_api_version = 15 } ) @@ -720,6 +789,9 @@ test.register_message_test( clusters.Thermostat.attributes.SystemMode:write(mock_device, 1, 3) } } + }, + { + min_api_version = 15 } ) @@ -758,6 +830,9 @@ test.register_message_test( FanMode:write(mock_device, 1, FanMode.ON) } }, + }, + { + min_api_version = 15 } ) @@ -780,6 +855,9 @@ test.register_message_test( clusters.FanControl.attributes.FanMode:write(mock_device, 1, 5) } } + }, + { + min_api_version = 15 } ) @@ -798,7 +876,11 @@ test.register_coroutine_test("Battery percent reports should generate correct me ) ) test.wait_for_events() -end) +end, +{ + min_api_version = 15 +} +) local refresh_request = nil local attribute_refresh_list = { @@ -849,6 +931,9 @@ test.register_message_test( refresh_request } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_composed_bridged.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_composed_bridged.lua index 1f1a4e6cb4..07e9e94604 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_composed_bridged.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_composed_bridged.lua @@ -35,6 +35,9 @@ local mock_device = test.mock_device.build_test_matter_device({ { cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER" }, { cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER" }, { cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat } } } @@ -67,6 +70,9 @@ local function test_init() subscribe_request:merge(cluster:subscribe(mock_device)) end end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) test.mock_device.add_test_device(mock_device) end @@ -90,6 +96,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) } + }, + { + min_api_version = 15 } ) @@ -110,6 +119,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -130,6 +142,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -156,6 +171,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -182,6 +200,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 40.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -202,6 +223,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.cooling()) } + }, + { + min_api_version = 15 } ) @@ -222,6 +246,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) } + }, + { + min_api_version = 15 } ) @@ -242,6 +269,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.fan_only()) } + }, + { + min_api_version = 15 } ) @@ -262,6 +292,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.idle()) } + }, + { + min_api_version = 15 } ) @@ -294,6 +327,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) }, + }, + { + min_api_version = 15 } ) @@ -344,6 +380,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({ "off", "cool" }, {visibility={displayed=false}})) }, + }, + { + min_api_version = 15 } ) @@ -383,6 +422,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat()) }, + }, + { + min_api_version = 15 } ) @@ -430,6 +472,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.thermostatFanMode.on()) }, + }, + { + min_api_version = 15 } ) @@ -465,6 +510,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.supportedThermostatFanModes({ "auto", "on" }, {visibility={displayed=false}})) }, + }, + { + min_api_version = 15 } ) @@ -487,6 +535,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 3, 15 * 100) } } + }, + { + min_api_version = 15 } ) @@ -509,6 +560,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 3, 25 * 100) } } + }, + { + min_api_version = 15 } ) @@ -531,6 +585,9 @@ test.register_message_test( clusters.Thermostat.attributes.SystemMode:write(mock_device, 3, 3) } } + }, + { + min_api_version = 15 } ) @@ -569,6 +626,9 @@ test.register_message_test( FanMode:write(mock_device, 3, FanMode.ON) } }, + }, + { + min_api_version = 15 } ) @@ -591,6 +651,9 @@ test.register_message_test( clusters.FanControl.attributes.FanMode:write(mock_device, 3, 5) } } + }, + { + min_api_version = 15 } ) @@ -609,7 +672,11 @@ test.register_coroutine_test("Battery percent reports should generate correct me ) ) test.wait_for_events() -end) +end, +{ + min_api_version = 15 +} +) local refresh_request = nil local attribute_refresh_list = { @@ -660,6 +727,9 @@ test.register_message_test( refresh_request } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua index 73d180da7e..b1aca3242e 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua @@ -2,6 +2,7 @@ -- Licensed under the Apache License, Version 2.0 local test = require "integration_test" +local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local clusters = require "st.matter.clusters" local im = require "st.matter.interaction_model" @@ -46,6 +47,43 @@ local mock_device_basic = test.mock_device.build_test_matter_device({ } }) +local mock_device_modular = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-modular.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + { + cluster_id = clusters.Thermostat.ID, + cluster_revision=5, + cluster_type="SERVER", + feature_map=3, -- Heat and Cool features + }, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + } + } +}) + -- create test_init functions local function initialize_mock_device(generic_mock_device, generic_subscribed_attributes) local subscribe_request = generic_subscribed_attributes[1]:subscribe(generic_mock_device) @@ -95,6 +133,9 @@ local function test_init() test.socket.matter:__expect_send({ mock_device_basic.id, read_request }) test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "init" }) + test.socket.capability:__expect_send( + mock_device_basic:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) subscribe_request_basic = initialize_mock_device(mock_device_basic, subscribed_attributes) end @@ -136,7 +177,168 @@ test.register_coroutine_test( function() test_thermostat_device_type_update_modular_profile(mock_device_basic, expected_metadata, subscribe_request_basic) end, - { test_init = test_init } + { + test_init = test_init, + min_api_version = 15 + } +) + +local function initialize_subscribe_request(mock_device, subscribed_attributes) + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in pairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + return subscribe_request +end + + +local function test_init_modular_fingerprint() + test.mock_device.add_test_device(mock_device_modular) + test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "init" }) + local subscribe_request = initialize_subscribe_request(mock_device_modular, { + [clusters.Thermostat.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + }, + [clusters.TemperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + }, + }) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) +end + +test.register_coroutine_test( +"Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", function() + local subscribe_request = initialize_subscribe_request(mock_device_modular, { + [clusters.Thermostat.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + }, + [clusters.TemperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + }, + [clusters.FanControl.ID] = { + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + }, + }) + local expected_metadata_modular = { + optional_component_capabilities={{"main", {"fanMode"}}}, + profile="thermostat-modular", + } + local updated_device_profile = t_utils.get_profile_definition("thermostat-modular.yml", + {enabled_optional_capabilities = expected_metadata_modular.optional_component_capabilities} + ) + updated_device_profile.id = "00000000-1111-2222-3333-000000000003" + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) + end, + { + test_init = test_init_modular_fingerprint, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({})) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_modular) end, + min_api_version = 15 + } +) + +local function initialize_subscribe_request(mock_device, subscribed_attributes) + local subscribe_request = nil + for _, attributes in pairs(subscribed_attributes) do + for _, attribute in pairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device) + else + subscribe_request:merge(attribute:subscribe(mock_device)) + end + end + end + return subscribe_request +end + + +local function test_init_modular_fingerprint() + test.mock_device.add_test_device(mock_device_modular) + test.socket.device_lifecycle:__queue_receive({ mock_device_modular.id, "init" }) + local subscribe_request = initialize_subscribe_request(mock_device_modular, { + [clusters.Thermostat.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + }, + [clusters.TemperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + }, + }) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) +end + +test.register_coroutine_test( +"Component-capability update without profile ID update should cause re-subscribe in infoChanged handler", function() + local subscribe_request = initialize_subscribe_request(mock_device_modular, { + [clusters.Thermostat.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + }, + [clusters.TemperatureMeasurement.ID] = { + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + }, + [clusters.FanControl.ID] = { + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + }, + }) + local expected_metadata_modular = { + optional_component_capabilities={{"main", {"fanMode"}}}, + profile="thermostat-modular", + } + local updated_device_profile = t_utils.get_profile_definition("thermostat-modular.yml", + {enabled_optional_capabilities = expected_metadata_modular.optional_component_capabilities} + ) + updated_device_profile.id = "00000000-1111-2222-3333-000000000003" + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device_modular.id, subscribe_request}) + end, + { + test_init = test_init_modular_fingerprint, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "No component-capability update and no profile ID update should not cause a re-subscribe in infoChanged handler", function() + -- simulate no actual change + test.socket.device_lifecycle:__queue_receive(mock_device_modular:generate_info_changed({})) + end, + { + test_init = function() test.mock_device.add_test_device(mock_device_modular) end, + min_api_version = 15 + } ) -- run tests diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_rpc5.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_rpc5.lua index 03e7df8ae2..6c05f671e5 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_rpc5.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_rpc5.lua @@ -2,6 +2,7 @@ -- Licensed under the Apache License, Version 2.0 local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" local test = require "integration_test" local t_utils = require "integration_test.utils" local utils = require "st.utils" @@ -39,6 +40,9 @@ local mock_device = test.mock_device.build_test_matter_device({ {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + }, + device_types = { + { device_type_id = 0x0301, device_type_revision = 1 } -- Thermostat } } } @@ -70,6 +74,9 @@ local function test_init() subscribe_request:merge(cluster:subscribe(mock_device)) end end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatOperatingState.supportedThermostatOperatingStates({"idle", "heating", "cooling"}, {visibility = {displayed = false}})) + ) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.mock_device.add_test_device(mock_device) end @@ -128,6 +135,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1, 35 * 100) } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_water_heater.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_water_heater.lua index b39db4136b..7c279a68c0 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_water_heater.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_water_heater.lua @@ -115,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 70.0, unit = "C" })) } + }, + { + min_api_version = 15 } ) @@ -137,6 +140,9 @@ test.register_message_test( clusters.Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, WATER_HEATER_EP, 80*100) } } + }, + { + min_api_version = 15 } ) @@ -196,6 +202,9 @@ test.register_message_test( clusters.WaterHeaterMode.commands.ChangeToMode(mock_device, WATER_HEATER_EP, 0) -- Index where Mode 1 is stored) } } + }, + { + min_api_version = 15 } ) @@ -225,6 +234,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -247,6 +259,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 15, unit = "Wh" })) } + }, + { + min_api_version = 15 } ) @@ -315,7 +330,8 @@ test.register_coroutine_test( { test_init = function() test_init() - end + end, + min_api_version = 15 } ) @@ -415,6 +431,9 @@ test.register_message_test( clusters.WaterHeaterMode.commands.ChangeToMode(mock_device, WATER_HEATER_EP, 0) -- Index is Water Heater Mode 1 } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_unsupported_unit_conversion.lua b/drivers/SmartThings/matter-thermostat/src/test/test_unsupported_unit_conversion.lua new file mode 100644 index 0000000000..6c29de18a9 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_unsupported_unit_conversion.lua @@ -0,0 +1,63 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" + +-- Unit conversion utilities for thermostat +local thermostat_utils = require "thermostat_utils.utils" +local thermostat_fields = require "thermostat_utils.fields" + +local units = thermostat_fields.units + +test.register_coroutine_test( + "Unsupported unit conversion from PPT to MGM3 should return nil", + function() + local result = thermostat_utils.convert_value_to_unit(100, units.PPT, units.MGM3, capabilities.ozoneMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported PPT to MGM3 conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unsupported unit conversion from PPB to UGM3 should return nil", + function() + local result = thermostat_utils.convert_value_to_unit(100, units.PPB, units.UGM3, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported PPB to UGM3 conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unsupported unit conversion from NGM3 to PPM should return nil", + function() + local result = thermostat_utils.convert_value_to_unit(100, units.NGM3, units.PPM, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil for unsupported NGM3 to PPM conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Supported unit conversion from PPM to PPB should return correct value", + function() + local result = thermostat_utils.convert_value_to_unit(100, units.PPM, units.PPB, capabilities.carbonMonoxideMeasurement.NAME) + assert(result ~= nil, "Expected a value for supported PPM to PPB conversion") + assert(result == 100000, "Expected 100000 for PPM to PPB conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Supported unit conversion from PPB to PPM should return correct value", + function() + local result = thermostat_utils.convert_value_to_unit(1000, units.PPB, units.PPM, capabilities.carbonMonoxideMeasurement.NAME) + assert(result ~= nil, "Expected a value for supported PPB to PPM conversion") + assert(result == 1, "Expected 1 for PPB to PPM conversion, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "Unit conversion with non-numeric value should return nil", + function() + local result = thermostat_utils.convert_value_to_unit("not a number", units.PPM, units.PPB, capabilities.carbonMonoxideMeasurement.NAME) + assert(result == nil, "Expected nil when value is not a number, got: " .. tostring(result)) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua index fd5dca06a4..e5b3728b61 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua @@ -48,11 +48,7 @@ function AttributeHandlers.system_mode_handler(driver, device, ib, response) return end - local supported_modes = device:get_latest_state( - device:endpoint_to_component(ib.endpoint_id), - capabilities.thermostatMode.ID, - capabilities.thermostatMode.supportedThermostatModes.NAME - ) or {} + local supported_modes = device:get_latest_state(device:endpoint_to_component(ib.endpoint_id), capabilities.thermostatMode.ID, capabilities.thermostatMode.supportedThermostatModes.NAME) or {} -- check that the given mode was in the supported modes list if thermostat_utils.tbl_contains(supported_modes, fields.THERMOSTAT_MODE_MAP[ib.data.value].NAME) then device:emit_event_for_endpoint(ib.endpoint_id, fields.THERMOSTAT_MODE_MAP[ib.data.value]()) @@ -88,9 +84,13 @@ end function AttributeHandlers.control_sequence_of_operation_handler(driver, device, ib, response) -- The ControlSequenceOfOperation attribute only directly specifies what can't be operated by the operating environment, not what can. -- However, we assert here that a Cooling enum value implies that SystemMode supports cooling, and the same for a Heating enum. - -- We also assert that Off is supported, though per spec this is optional. + -- We also assert that Off is supported if the switch capability is not supported, though per spec this is optional. if device:get_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN) == nil then - device:set_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN, {capabilities.thermostatMode.thermostatMode.off.NAME}, {persist=true}) + if device:supports_capability(capabilities.switch) == false then + device:set_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN, {capabilities.thermostatMode.thermostatMode.off.NAME}, {persist=true}) + else + device:set_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN, {}, {persist=true}) + end end local supported_modes = st_utils.deep_copy(device:get_field(fields.OPTIONAL_THERMOSTAT_MODES_SEEN)) local disallowed_mode_operations = {} @@ -329,19 +329,19 @@ function AttributeHandlers.fan_mode_handler(driver, device, ib, response) end function AttributeHandlers.fan_mode_sequence_handler(driver, device, ib, response) - local supported_fan_modes, supported_fan_modes_attribute + local supportedFanModes, supported_fan_modes_attribute if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then - supported_fan_modes = { "off", "low", "medium", "high" } + supportedFanModes = { "off", "low", "medium", "high" } elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then - supported_fan_modes = { "off", "low", "high" } + supportedFanModes = { "off", "low", "high" } elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then - supported_fan_modes = { "off", "low", "medium", "high", "auto" } + supportedFanModes = { "off", "low", "medium", "high", "auto" } elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then - supported_fan_modes = { "off", "low", "high", "auto" } + supportedFanModes = { "off", "low", "high", "auto" } elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_HIGH_AUTO then - supported_fan_modes = { "off", "high", "auto" } + supportedFanModes = { "off", "high", "auto" } else - supported_fan_modes = { "off", "high" } + supportedFanModes = { "off", "high" } end if device:supports_capability_by_id(capabilities.airPurifierFanMode.ID) then @@ -353,22 +353,16 @@ function AttributeHandlers.fan_mode_sequence_handler(driver, device, ib, respons -- Our thermostat fan mode control is not granular enough to handle all of the supported modes if ib.data.value >= clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO and ib.data.value <= clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then - supported_fan_modes = { "auto", "on" } + supportedFanModes = { "auto", "on" } else - supported_fan_modes = { "on" } + supportedFanModes = { "on" } end else supported_fan_modes_attribute = capabilities.fanMode.supportedFanModes end - -- remove 'off' as a supported fan mode for thermostat device types - if thermostat_utils.get_device_type(device) == fields.THERMOSTAT_DEVICE_TYPE_ID and - device:supports_capability_by_id(capabilities.fanMode.ID) then - -- per the definitions set above, the first index always contains "off" - table.remove(supported_fan_modes, 1) - end - - device:emit_event_for_endpoint(ib.endpoint_id, supported_fan_modes_attribute(supported_fan_modes, {visibility = {displayed = false}})) + local event = supported_fan_modes_attribute(supportedFanModes, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ib.endpoint_id, event) end function AttributeHandlers.percent_current_handler(driver, device, ib, response) @@ -500,23 +494,15 @@ end function AttributeHandlers.concentration_measured_value_factory(capability_name, attribute, target_unit) return function(driver, device, ib, response) - local reporting_unit = device:get_field(capability_name.."_unit") - - if not reporting_unit then - reporting_unit = fields.unit_default[capability_name] - device:set_field(capability_name.."_unit", reporting_unit, {persist = true}) - end - - local value = nil - if reporting_unit then - value = thermostat_utils.unit_conversion(ib.data.value, reporting_unit, target_unit, capability_name) - end + if not ib.data.value then return end + local reporting_unit = device:get_field(capability_name.."_unit") or fields.unit_default[capability_name] + local converted_value = thermostat_utils.convert_value_to_unit(ib.data.value, reporting_unit, target_unit, capability_name) - if value then - device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = value, unit = fields.unit_strings[target_unit]})) + if converted_value then + device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = converted_value, unit = fields.unit_strings[target_unit]})) -- handle case where device profile supports both fineDustLevel and dustLevel if capability_name == capabilities.fineDustSensor.NAME and device:supports_capability(capabilities.dustSensor) then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.dustSensor.fineDustLevel({value = value, unit = fields.unit_strings[target_unit]})) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.dustSensor.fineDustLevel({value = converted_value, unit = fields.unit_strings[target_unit]})) end end end diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua index 770fd7d65e..ddedab385d 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua @@ -1,6 +1,7 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local log = require "log" local version = require "version" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" @@ -205,13 +206,24 @@ ThermostatFields.molecular_weights = { [capabilities.tvocMeasurement.NAME] = "N/A", } -ThermostatFields.conversion_tables = { +local function is_valid_molecular_weight(molecular_weight) + if type(molecular_weight) ~= "number" or molecular_weight <= 0 then + log.warn_with({hub_logs = true}, string.format("unit conversion molecular weight (%s) is not a valid number", molecular_weight)) + return false + end + return true +end + +ThermostatFields.unit_conversion = { [units.PPM] = { [units.PPM] = function(value) return st_utils.round(value) end, [units.PPB] = function(value) return st_utils.round(value * (10^3)) end, - [units.UGM3] = function(value, molecular_weight) return st_utils.round((value * molecular_weight * 10^3) / ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) end, - [units.MGM3] = function(value, molecular_weight) return st_utils.round((value * molecular_weight) / ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) end, - }, + [units.UGM3] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * molecular_weight * 10^3) / ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) end + end, + [units.MGM3] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * molecular_weight) / ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) end + end,}, [units.PPB] = { [units.PPM] = function(value) return st_utils.round(value/(10^3)) end, [units.PPB] = function(value) return st_utils.round(value) end, @@ -221,11 +233,15 @@ ThermostatFields.conversion_tables = { }, [units.MGM3] = { [units.UGM3] = function(value) return st_utils.round(value * (10^3)) end, - [units.PPM] = function(value, molecular_weight) return st_utils.round((value * ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) / molecular_weight) end, + [units.PPM] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) / molecular_weight) end + end, }, [units.UGM3] = { [units.UGM3] = function(value) return st_utils.round(value) end, - [units.PPM] = function(value, molecular_weight) return st_utils.round((value * ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) / (molecular_weight * 10^3)) end, + [units.PPM] = function(value, molecular_weight) if is_valid_molecular_weight(molecular_weight) then + return st_utils.round((value * ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) / (molecular_weight * 10^3)) end + end, }, [units.NGM3] = { [units.UGM3] = function(value) return st_utils.round(value/(10^3)) end diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua index 8194f08c48..940fe9835e 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua @@ -3,8 +3,9 @@ local log = require "log" local capabilities = require "st.capabilities" -local embedded_cluster_utils = require "thermostat_utils.embedded_cluster_utils" +local clusters = require "st.matter.clusters" local fields = require "thermostat_utils.fields" +local embedded_cluster_utils = require "thermostat_utils.embedded_cluster_utils" local ThermostatUtils = {} @@ -71,6 +72,50 @@ function ThermostatUtils.get_total_cumulative_energy_imported(device) return total_energy end +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function ThermostatUtils.deep_equals(a, b, opts, seen) + if a == b then return true end -- same object + if type(a) ~= type(b) then return false end -- different type + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end -- same type but not table, thus was already compared + + -- check for cycles in table references and preserve reference topology. + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + -- Compare keys/values from a + for k, v in pairs(a) do + if not ThermostatUtils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + -- Ensure b doesn't have extra keys + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + -- Compare metatables + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return ThermostatUtils.deep_equals(mt_a, mt_b, opts, seen) +end + function ThermostatUtils.get_endpoints_by_device_type(device, device_type) local endpoints = {} for _, ep in ipairs(device.endpoints) do @@ -85,6 +130,25 @@ function ThermostatUtils.get_endpoints_by_device_type(device, device_type) return endpoints end + -- set the supportedThermostatOperatingStates attribute if the thermostatOperatingState capability is supported and it has not been set before +function ThermostatUtils.handle_thermostat_operating_state_info(device) + local thermostat_operating_state_supported = device:supports_capability(capabilities.thermostatOperatingState) + local latest_supported_operating_states = thermostat_operating_state_supported and device:get_latest_state( + "main", capabilities.thermostatOperatingState.ID, capabilities.thermostatOperatingState.supportedThermostatOperatingStates.NAME + ) + if thermostat_operating_state_supported and latest_supported_operating_states == nil then + local supported_operating_modes = { "idle" } + if #device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.HEATING}) > 0 then + table.insert(supported_operating_modes, "heating") + end + if #device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.COOLING}) > 0 then + table.insert(supported_operating_modes, "cooling") + end + local thermostat_ep_id = device:get_endpoints(clusters.Thermostat.ID)[1] + device:emit_event_for_endpoint(thermostat_ep_id, capabilities.thermostatOperatingState.supportedThermostatOperatingStates(supported_operating_modes, {visibility = {displayed = false}})) + end +end + function ThermostatUtils.get_device_type(device) -- For cases where a device has multiple device types, this list indicates which -- device type will be the "main" device type for purposes of selecting a profile @@ -116,18 +180,15 @@ function ThermostatUtils.get_device_type(device) return main_device_type end -function ThermostatUtils.unit_conversion(value, from_unit, to_unit, capability_name) - local conversion_function = fields.conversion_tables[from_unit] and fields.conversion_tables[from_unit][to_unit] or nil +function ThermostatUtils.convert_value_to_unit(value, from_unit, to_unit, capability_name) + local conversion_function = fields.unit_conversion[from_unit] and fields.unit_conversion[from_unit][to_unit] if not conversion_function then log.info_with( {hub_logs = true} , string.format("Unsupported unit conversion from %s to %s", fields.unit_strings[from_unit], fields.unit_strings[to_unit])) return - end - - if not value then - log.info_with( {hub_logs = true} , "unit conversion value is nil") + elseif type(value) ~= "number" then + log.info_with( {hub_logs = true} , string.format("unit conversion value (%s) is not a number", value)) return end - return conversion_function(value, fields.molecular_weights[capability_name]) end diff --git a/drivers/SmartThings/matter-window-covering/fingerprints.yml b/drivers/SmartThings/matter-window-covering/fingerprints.yml index 532ba1f304..66b33f0bf7 100644 --- a/drivers/SmartThings/matter-window-covering/fingerprints.yml +++ b/drivers/SmartThings/matter-window-covering/fingerprints.yml @@ -36,6 +36,12 @@ matterManufacturer: vendorId: 0x153B productId: 0x3801 deviceProfileName: window-covering-tilt +# HooRii + - id: "4945/61171" + deviceLabel: HooRii Window Covering + vendorId: 0x1351 + productId: 0xEEF3 + deviceProfileName: window-covering-battery # Meross - id: "4933/61453" deviceLabel: Smart Wi-Fi Roller Shutter Timer @@ -101,6 +107,12 @@ matterManufacturer: vendorId: 0x139C productId: 0xFA17 deviceProfileName: window-covering +#WINDOWSTORY + - id: "5496/6657" + deviceLabel: GATEWAY-MT + vendorId: 0x1578 + productId: 0x1A01 + deviceProfileName: window-covering-tilt #WISTAR - id: "5207/3" deviceLabel: WISTAR WSERD16-B Smart Tubular Motor @@ -176,7 +188,7 @@ matterManufacturer: deviceLabel: WISTAR WSCMXF-LED Smart Vertical Blind Motor vendorId: 0x1457 productId: 0x0018 - deviceProfileName: window-covering-tilt + deviceProfileName: window-covering-tilt - id: "5207/20" deviceLabel: WISTAR WSCMQ Smart Curtain Motor vendorId: 0x1457 @@ -243,9 +255,20 @@ 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 deviceTypes: - id: 0x0202 # Window Covering deviceProfileName: window-covering + - id: "closure" + deviceLabel: Matter Closure + deviceTypes: + - id: 0x0230 # Closure + deviceProfileName: covering diff --git a/drivers/SmartThings/matter-window-covering/profiles/covering.yml b/drivers/SmartThings/matter-window-covering/profiles/covering.yml new file mode 100644 index 0000000000..11e2319a5a --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/covering.yml @@ -0,0 +1,56 @@ +name: covering +components: +- id: main + capabilities: + - id: windowShade + version: 1 + - id: windowShadeLevel + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Blind +- id: windowShade1 + optional: true + capabilities: + - id: windowShadeLevel + version: 1 + optional: true + categories: + - name: Blind +- id: windowShade2 + optional: true + capabilities: + - id: windowShadeLevel + version: 1 + optional: true + categories: + - name: Blind +- id: windowShade3 + optional: true + capabilities: + - id: windowShadeLevel + version: 1 + optional: true + categories: + - name: Blind +- id: windowShade4 + optional: true + capabilities: + - id: windowShadeLevel + version: 1 + optional: true + categories: + - name: Blind +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/door.yml b/drivers/SmartThings/matter-window-covering/profiles/door.yml new file mode 100644 index 0000000000..bd19c2dc8f --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/door.yml @@ -0,0 +1,56 @@ +name: door +components: +- id: main + capabilities: + - id: doorControl + version: 1 + - id: level + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Door +- id: door1 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door2 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door3 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door4 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/garage-door.yml b/drivers/SmartThings/matter-window-covering/profiles/garage-door.yml new file mode 100644 index 0000000000..a75ae63580 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/garage-door.yml @@ -0,0 +1,56 @@ +name: garage-door +components: +- id: main + capabilities: + - id: doorControl + version: 1 + - id: level + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: GarageDoor +- id: door1 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: GarageDoor +- id: door2 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: GarageDoor +- id: door3 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: GarageDoor +- id: door4 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: GarageDoor +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/gate.yml b/drivers/SmartThings/matter-window-covering/profiles/gate.yml new file mode 100644 index 0000000000..8cc49b425f --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/profiles/gate.yml @@ -0,0 +1,56 @@ +name: gate +components: +- id: main + capabilities: + - id: doorControl + version: 1 + - id: level + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Door +- id: door1 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door2 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door3 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +- id: door4 + optional: true + capabilities: + - id: level + version: 1 + optional: true + categories: + - name: Door +preferences: + - preferenceId: reverse + explicit: true diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml index a1896ca634..ad2dc6f0ac 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml index fa46d87cc6..c541d47ae8 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: batteryLevel version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml index 903565e68c..94e98998b3 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml @@ -9,6 +9,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml index 27f7f1f61a..bca45d37af 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: windowShadeTiltLevel version: 1 - id: battery diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml index c6a759b610..99a579e2c6 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: windowShadeTiltLevel version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml index fbd40ed08d..e90a3fdb23 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/init.lua new file mode 100644 index 0000000000..76adc03375 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/init.lua @@ -0,0 +1,97 @@ +local cluster_base = require "st.matter.cluster_base" +local ClosureControlServerAttributes = require "embedded_clusters.ClosureControl.server.attributes" +local ClosureControlServerCommands = require "embedded_clusters.ClosureControl.server.commands" +local ClosureControlTypes = require "embedded_clusters.ClosureControl.types" + +local ClosureControl = {} + +ClosureControl.ID = 0x0104 +ClosureControl.NAME = "ClosureControl" +ClosureControl.server = {} +ClosureControl.client = {} +ClosureControl.server.attributes = ClosureControlServerAttributes:set_parent_cluster(ClosureControl) +ClosureControl.server.commands = ClosureControlServerCommands:set_parent_cluster(ClosureControl) +ClosureControl.types = ClosureControlTypes + +function ClosureControl:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "CountdownTime", + [0x0001] = "MainState", + [0x0002] = "CurrentErrorList", + [0x0003] = "OverallCurrentState", + [0x0004] = "OverallTargetState", + [0x0005] = "LatchControlModes", + [0xFFF9] = "AcceptedCommandList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ClosureControl:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "Stop", + [0x0001] = "MoveTo", + [0x0002] = "Calibrate", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + + +ClosureControl.attribute_direction_map = { + ["CountdownTime"] = "server", + ["MainState"] = "server", + ["CurrentErrorList"] = "server", + ["OverallCurrentState"] = "server", + ["OverallTargetState"] = "server", + ["LatchControlModes"] = "server", + ["AcceptedCommandList"] = "server", + ["AttributeList"] = "server", +} + +ClosureControl.command_direction_map = { + ["Stop"] = "server", + ["MoveTo"] = "server", + ["Calibrate"] = "server", +} + +ClosureControl.FeatureMap = ClosureControl.types.Feature + +function ClosureControl.are_features_supported(feature, feature_map) + if (ClosureControl.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ClosureControl.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ClosureControl.NAME)) + end + return ClosureControl[direction].attributes[key] +end +ClosureControl.attributes = {} +setmetatable(ClosureControl.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ClosureControl.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ClosureControl.NAME)) + end + return ClosureControl[direction].commands[key] +end +ClosureControl.commands = {} +setmetatable(ClosureControl.commands, command_helper_mt) + +setmetatable(ClosureControl, {__index = cluster_base}) + +return ClosureControl diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AcceptedCommandList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AcceptedCommandList.lua new file mode 100644 index 0000000000..f86ac60dbe --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AcceptedCommandList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AcceptedCommandList = { + ID = 0xFFF9, + NAME = "AcceptedCommandList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AcceptedCommandList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AcceptedCommandList.element_type) + end +end + +function AcceptedCommandList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AcceptedCommandList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AcceptedCommandList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AcceptedCommandList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AcceptedCommandList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AcceptedCommandList, {__call = AcceptedCommandList.new_value, __index = AcceptedCommandList.base_type}) +return AcceptedCommandList diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AttributeList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AttributeList.lua new file mode 100644 index 0000000000..7f6827b026 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/AttributeList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local AttributeList = { + ID = 0xFFFB, + NAME = "AttributeList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint32", +} + +function AttributeList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, AttributeList.element_type) + end +end + +function AttributeList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function AttributeList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function AttributeList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function AttributeList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function AttributeList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(AttributeList, {__call = AttributeList.new_value, __index = AttributeList.base_type}) +return AttributeList diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CountdownTime.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CountdownTime.lua new file mode 100644 index 0000000000..b6e4f861b6 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CountdownTime.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CountdownTime = { + ID = 0x0000, + NAME = "CountdownTime", + base_type = require "st.matter.data_types.Uint32", +} + +function CountdownTime:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function CountdownTime:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CountdownTime:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CountdownTime:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CountdownTime:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CountdownTime:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(CountdownTime, {__call = CountdownTime.new_value, __index = CountdownTime.base_type}) +return CountdownTime diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CurrentErrorList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CurrentErrorList.lua new file mode 100644 index 0000000000..21076e7e5d --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/CurrentErrorList.lua @@ -0,0 +1,74 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentErrorList = { + ID = 0x0002, + NAME = "CurrentErrorList", + base_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.ClosureControl.types.ClosureErrorEnum", +} + +function CurrentErrorList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, CurrentErrorList.element_type) + end +end + +function CurrentErrorList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CurrentErrorList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentErrorList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentErrorList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentErrorList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentErrorList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CurrentErrorList, {__call = CurrentErrorList.new_value, __index = CurrentErrorList.base_type}) +return CurrentErrorList diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/LatchControlModes.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/LatchControlModes.lua new file mode 100644 index 0000000000..d62831b4f3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/LatchControlModes.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local LatchControlModes = { + ID = 0x0005, + NAME = "LatchControlModes", + base_type = require "embedded_clusters.ClosureControl.types.LatchControlModesBitmap", +} + +function LatchControlModes:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function LatchControlModes:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LatchControlModes:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LatchControlModes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LatchControlModes:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function LatchControlModes:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(LatchControlModes, {__call = LatchControlModes.new_value, __index = LatchControlModes.base_type}) +return LatchControlModes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/MainState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/MainState.lua new file mode 100644 index 0000000000..5f1bbc06e2 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/MainState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MainState = { + ID = 0x0001, + NAME = "MainState", + base_type = require "embedded_clusters.ClosureControl.types.MainStateEnum", +} + +function MainState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function MainState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MainState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function MainState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MainState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function MainState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MainState, {__call = MainState.new_value, __index = MainState.base_type}) +return MainState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallCurrentState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallCurrentState.lua new file mode 100644 index 0000000000..f93477ec47 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallCurrentState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OverallCurrentState = { + ID = 0x0003, + NAME = "OverallCurrentState", + base_type = require "embedded_clusters.ClosureControl.types.OverallCurrentStateStruct", +} + +function OverallCurrentState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OverallCurrentState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OverallCurrentState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OverallCurrentState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OverallCurrentState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OverallCurrentState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OverallCurrentState, {__call = OverallCurrentState.new_value, __index = OverallCurrentState.base_type}) +return OverallCurrentState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallTargetState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallTargetState.lua new file mode 100644 index 0000000000..6c8cbb9ee3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/OverallTargetState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local OverallTargetState = { + ID = 0x0004, + NAME = "OverallTargetState", + base_type = require "embedded_clusters.ClosureControl.types.OverallTargetStateStruct", +} + +function OverallTargetState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function OverallTargetState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OverallTargetState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function OverallTargetState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function OverallTargetState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function OverallTargetState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(OverallTargetState, {__call = OverallTargetState.new_value, __index = OverallTargetState.base_type}) +return OverallTargetState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/init.lua new file mode 100644 index 0000000000..0c71152760 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/attributes/init.lua @@ -0,0 +1,19 @@ +local attr_mt = {} +attr_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.ClosureControl.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local ClosureControlServerAttributes = {} + +function ClosureControlServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ClosureControlServerAttributes, attr_mt) + +return ClosureControlServerAttributes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Calibrate.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Calibrate.lua new file mode 100644 index 0000000000..1a2931c0ad --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Calibrate.lua @@ -0,0 +1,91 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Calibrate = {} + +Calibrate.NAME = "Calibrate" +Calibrate.ID = 0x0002 +Calibrate.field_defs = { +} + +function Calibrate:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function Calibrate:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Calibrate, + __tostring = Calibrate.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function Calibrate:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Calibrate:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Calibrate:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Calibrate, {__call = Calibrate.init}) + +return Calibrate diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/MoveTo.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/MoveTo.lua new file mode 100644 index 0000000000..fb83ed49c2 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/MoveTo.lua @@ -0,0 +1,112 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local MoveTo = {} + +MoveTo.NAME = "MoveTo" +MoveTo.ID = 0x0001 +MoveTo.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.ClosureControl.types.TargetPositionEnum", + }, + { + name = "latch", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +function MoveTo:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function MoveTo:init(device, endpoint_id, position, latch, speed) + local out = {} + local args = {position, latch, speed} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = MoveTo, + __tostring = MoveTo.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function MoveTo:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function MoveTo:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function MoveTo:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(MoveTo, {__call = MoveTo.init}) + +return MoveTo diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Stop.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Stop.lua new file mode 100644 index 0000000000..9ac41ba122 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/Stop.lua @@ -0,0 +1,90 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Stop = {} + +Stop.NAME = "Stop" +Stop.ID = 0x0000 +Stop.field_defs = { +} + +function Stop:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function Stop:init(device, endpoint_id) + local out = {} + local args = {} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Stop, + __tostring = Stop.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID + ) +end + +function Stop:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Stop:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Stop:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Stop, {__call = Stop.init}) + +return Stop diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/init.lua new file mode 100644 index 0000000000..1125ef4296 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/server/commands/init.lua @@ -0,0 +1,19 @@ +local command_mt = {} +command_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.ClosureControl.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local ClosureControlServerCommands = {} + +function ClosureControlServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ClosureControlServerCommands, command_mt) + +return ClosureControlServerCommands diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/ClosureErrorEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/ClosureErrorEnum.lua new file mode 100644 index 0000000000..5e5a09da56 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/ClosureErrorEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ClosureErrorEnum = {} +local new_mt = UintABC.new_mt({NAME = "ClosureErrorEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.PHYSICALLY_BLOCKED] = "PHYSICALLY_BLOCKED", + [self.BLOCKED_BY_SENSOR] = "BLOCKED_BY_SENSOR", + [self.TEMPERATURE_LIMITED] = "TEMPERATURE_LIMITED", + [self.MAINTENANCE_REQUIRED] = "MAINTENANCE_REQUIRED", + [self.INTERNAL_INTERFERENCE] = "INTERNAL_INTERFERENCE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.PHYSICALLY_BLOCKED = 0x00 +new_mt.__index.BLOCKED_BY_SENSOR = 0x01 +new_mt.__index.TEMPERATURE_LIMITED = 0x02 +new_mt.__index.MAINTENANCE_REQUIRED = 0x03 +new_mt.__index.INTERNAL_INTERFERENCE = 0x04 + +ClosureErrorEnum.PHYSICALLY_BLOCKED = 0x00 +ClosureErrorEnum.BLOCKED_BY_SENSOR = 0x01 +ClosureErrorEnum.TEMPERATURE_LIMITED = 0x02 +ClosureErrorEnum.MAINTENANCE_REQUIRED = 0x03 +ClosureErrorEnum.INTERNAL_INTERFERENCE = 0x04 + +ClosureErrorEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ClosureErrorEnum, new_mt) + +return ClosureErrorEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/CurrentPositionEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/CurrentPositionEnum.lua new file mode 100644 index 0000000000..a5fee86d89 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/CurrentPositionEnum.lua @@ -0,0 +1,39 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local CurrentPositionEnum = {} +local new_mt = UintABC.new_mt({NAME = "CurrentPositionEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.FULLY_CLOSED] = "FULLY_CLOSED", + [self.FULLY_OPENED] = "FULLY_OPENED", + [self.PARTIALLY_OPENED] = "PARTIALLY_OPENED", + [self.OPENED_FOR_PEDESTRIAN] = "OPENED_FOR_PEDESTRIAN", + [self.OPENED_FOR_VENTILATION] = "OPENED_FOR_VENTILATION", + [self.OPENED_AT_SIGNATURE] = "OPENED_AT_SIGNATURE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.FULLY_CLOSED = 0x00 +new_mt.__index.FULLY_OPENED = 0x01 +new_mt.__index.PARTIALLY_OPENED = 0x02 +new_mt.__index.OPENED_FOR_PEDESTRIAN = 0x03 +new_mt.__index.OPENED_FOR_VENTILATION = 0x04 +new_mt.__index.OPENED_AT_SIGNATURE = 0x05 + +CurrentPositionEnum.FULLY_CLOSED = 0x00 +CurrentPositionEnum.FULLY_OPENED = 0x01 +CurrentPositionEnum.PARTIALLY_OPENED = 0x02 +CurrentPositionEnum.OPENED_FOR_PEDESTRIAN = 0x03 +CurrentPositionEnum.OPENED_FOR_VENTILATION = 0x04 +CurrentPositionEnum.OPENED_AT_SIGNATURE = 0x05 + +CurrentPositionEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(CurrentPositionEnum, new_mt) + +return CurrentPositionEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/Feature.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/Feature.lua new file mode 100644 index 0000000000..e41485dbe7 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/Feature.lua @@ -0,0 +1,228 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.POSITIONING = 0x0001 +Feature.MOTION_LATCHING = 0x0002 +Feature.INSTANTANEOUS = 0x0004 +Feature.SPEED = 0x0008 +Feature.VENTILATION = 0x0010 +Feature.PEDESTRIAN = 0x0020 +Feature.CALIBRATION = 0x0040 +Feature.PROTECTION = 0x0080 +Feature.MANUALLY_OPERABLE = 0x0100 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + POSITIONING = 0x0001, + MOTION_LATCHING = 0x0002, + INSTANTANEOUS = 0x0004, + SPEED = 0x0008, + VENTILATION = 0x0010, + PEDESTRIAN = 0x0020, + CALIBRATION = 0x0040, + PROTECTION = 0x0080, + MANUALLY_OPERABLE = 0x0100, +} + +Feature.is_positioning_set = function(self) + return (self.value & self.POSITIONING) ~= 0 +end + +Feature.set_positioning = function(self) + if self.value ~= nil then + self.value = self.value | self.POSITIONING + else + self.value = self.POSITIONING + end +end + +Feature.unset_positioning = function(self) + self.value = self.value & (~self.POSITIONING & self.BASE_MASK) +end +Feature.is_motion_latching_set = function(self) + return (self.value & self.MOTION_LATCHING) ~= 0 +end + +Feature.set_motion_latching = function(self) + if self.value ~= nil then + self.value = self.value | self.MOTION_LATCHING + else + self.value = self.MOTION_LATCHING + end +end + +Feature.unset_motion_latching = function(self) + self.value = self.value & (~self.MOTION_LATCHING & self.BASE_MASK) +end + +Feature.is_instantaneous_set = function(self) + return (self.value & self.INSTANTANEOUS) ~= 0 +end + +Feature.set_instantaneous = function(self) + if self.value ~= nil then + self.value = self.value | self.INSTANTANEOUS + else + self.value = self.INSTANTANEOUS + end +end + +Feature.unset_instantaneous = function(self) + self.value = self.value & (~self.INSTANTANEOUS & self.BASE_MASK) +end + +Feature.is_speed_set = function(self) + return (self.value & self.SPEED) ~= 0 +end + +Feature.set_speed = function(self) + if self.value ~= nil then + self.value = self.value | self.SPEED + else + self.value = self.SPEED + end +end + +Feature.unset_speed = function(self) + self.value = self.value & (~self.SPEED & self.BASE_MASK) +end + +Feature.is_ventilation_set = function(self) + return (self.value & self.VENTILATION) ~= 0 +end + +Feature.set_ventilation = function(self) + if self.value ~= nil then + self.value = self.value | self.VENTILATION + else + self.value = self.VENTILATION + end +end + +Feature.unset_ventilation = function(self) + self.value = self.value & (~self.VENTILATION & self.BASE_MASK) +end + +Feature.is_pedestrian_set = function(self) + return (self.value & self.PEDESTRIAN) ~= 0 +end + +Feature.set_pedestrian = function(self) + if self.value ~= nil then + self.value = self.value | self.PEDESTRIAN + else + self.value = self.PEDESTRIAN + end +end + +Feature.unset_pedestrian = function(self) + self.value = self.value & (~self.PEDESTRIAN & self.BASE_MASK) +end + +Feature.is_calibration_set = function(self) + return (self.value & self.CALIBRATION) ~= 0 +end + +Feature.set_calibration = function(self) + if self.value ~= nil then + self.value = self.value | self.CALIBRATION + else + self.value = self.CALIBRATION + end +end + +Feature.unset_calibration = function(self) + self.value = self.value & (~self.CALIBRATION & self.BASE_MASK) +end + +Feature.is_protection_set = function(self) + return (self.value & self.PROTECTION) ~= 0 +end + +Feature.set_protection = function(self) + if self.value ~= nil then + self.value = self.value | self.PROTECTION + else + self.value = self.PROTECTION + end +end + +Feature.unset_protection = function(self) + self.value = self.value & (~self.PROTECTION & self.BASE_MASK) +end + +Feature.is_manually_operable_set = function(self) + return (self.value & self.MANUALLY_OPERABLE) ~= 0 +end + +Feature.set_manually_operable = function(self) + if self.value ~= nil then + self.value = self.value | self.MANUALLY_OPERABLE + else + self.value = self.MANUALLY_OPERABLE + end +end + +Feature.unset_manually_operable = function(self) + self.value = self.value & (~self.MANUALLY_OPERABLE & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.POSITIONING | + Feature.MOTION_LATCHING | + Feature.INSTANTANEOUS | + Feature.SPEED | + Feature.VENTILATION | + Feature.PEDESTRIAN | + Feature.CALIBRATION | + Feature.PROTECTION | + Feature.MANUALLY_OPERABLE + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_positioning_set = Feature.is_positioning_set, + set_positioning = Feature.set_positioning, + unset_positioning = Feature.unset_positioning, + is_motion_latching_set = Feature.is_motion_latching_set, + set_motion_latching = Feature.set_motion_latching, + unset_motion_latching = Feature.unset_motion_latching, + is_instantaneous_set = Feature.is_instantaneous_set, + set_instantaneous = Feature.set_instantaneous, + unset_instantaneous = Feature.unset_instantaneous, + is_speed_set = Feature.is_speed_set, + set_speed = Feature.set_speed, + unset_speed = Feature.unset_speed, + is_ventilation_set = Feature.is_ventilation_set, + set_ventilation = Feature.set_ventilation, + unset_ventilation = Feature.unset_ventilation, + is_pedestrian_set = Feature.is_pedestrian_set, + set_pedestrian = Feature.set_pedestrian, + unset_pedestrian = Feature.unset_pedestrian, + is_calibration_set = Feature.is_calibration_set, + set_calibration = Feature.set_calibration, + unset_calibration = Feature.unset_calibration, + is_protection_set = Feature.is_protection_set, + set_protection = Feature.set_protection, + unset_protection = Feature.unset_protection, + is_manually_operable_set = Feature.is_manually_operable_set, + set_manually_operable = Feature.set_manually_operable, + unset_manually_operable = Feature.unset_manually_operable, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/LatchControlModesBitmap.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/LatchControlModesBitmap.lua new file mode 100644 index 0000000000..ba6188e4a3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/LatchControlModesBitmap.lua @@ -0,0 +1,64 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local LatchControlModesBitmap = {} +local new_mt = UintABC.new_mt({NAME = "LatchControlModesBitmap", ID = data_types.name_to_id_map["Uint8"]}, 1) + +LatchControlModesBitmap.BASE_MASK = 0xFFFF +LatchControlModesBitmap.REMOTE_LATCHING = 0x0001 +LatchControlModesBitmap.REMOTE_UNLATCHING = 0x0002 + +LatchControlModesBitmap.mask_fields = { + BASE_MASK = 0xFFFF, + REMOTE_LATCHING = 0x0001, + REMOTE_UNLATCHING = 0x0002, +} + +LatchControlModesBitmap.is_remote_latching_set = function(self) + return (self.value & self.REMOTE_LATCHING) ~= 0 +end + +LatchControlModesBitmap.set_remote_latching = function(self) + if self.value ~= nil then + self.value = self.value | self.REMOTE_LATCHING + else + self.value = self.REMOTE_LATCHING + end +end + +LatchControlModesBitmap.unset_remote_latching = function(self) + self.value = self.value & (~self.REMOTE_LATCHING & self.BASE_MASK) +end + +LatchControlModesBitmap.is_remote_unlatching_set = function(self) + return (self.value & self.REMOTE_UNLATCHING) ~= 0 +end + +LatchControlModesBitmap.set_remote_unlatching = function(self) + if self.value ~= nil then + self.value = self.value | self.REMOTE_UNLATCHING + else + self.value = self.REMOTE_UNLATCHING + end +end + +LatchControlModesBitmap.unset_remote_unlatching = function(self) + self.value = self.value & (~self.REMOTE_UNLATCHING & self.BASE_MASK) +end + +LatchControlModesBitmap.mask_methods = { + is_remote_latching_set = LatchControlModesBitmap.is_remote_latching_set, + set_remote_latching = LatchControlModesBitmap.set_remote_latching, + unset_remote_latching = LatchControlModesBitmap.unset_remote_latching, + is_remote_unlatching_set = LatchControlModesBitmap.is_remote_unlatching_set, + set_remote_unlatching = LatchControlModesBitmap.set_remote_unlatching, + unset_remote_unlatching = LatchControlModesBitmap.unset_remote_unlatching, +} + +LatchControlModesBitmap.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(LatchControlModesBitmap, new_mt) + +return LatchControlModesBitmap diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/MainStateEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/MainStateEnum.lua new file mode 100644 index 0000000000..916e27dda0 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/MainStateEnum.lua @@ -0,0 +1,45 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local MainStateEnum = {} +local new_mt = UintABC.new_mt({NAME = "MainStateEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.STOPPED] = "STOPPED", + [self.MOVING] = "MOVING", + [self.WAITING_FOR_MOTION] = "WAITING_FOR_MOTION", + [self.ERROR] = "ERROR", + [self.CALIBRATING] = "CALIBRATING", + [self.PROTECTED] = "PROTECTED", + [self.DISENGAGED] = "DISENGAGED", + [self.SETUP_REQUIRED] = "SETUP_REQUIRED", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.STOPPED = 0x00 +new_mt.__index.MOVING = 0x01 +new_mt.__index.WAITING_FOR_MOTION = 0x02 +new_mt.__index.ERROR = 0x03 +new_mt.__index.CALIBRATING = 0x04 +new_mt.__index.PROTECTED = 0x05 +new_mt.__index.DISENGAGED = 0x06 +new_mt.__index.SETUP_REQUIRED = 0x07 + +MainStateEnum.STOPPED = 0x00 +MainStateEnum.MOVING = 0x01 +MainStateEnum.WAITING_FOR_MOTION = 0x02 +MainStateEnum.ERROR = 0x03 +MainStateEnum.CALIBRATING = 0x04 +MainStateEnum.PROTECTED = 0x05 +MainStateEnum.DISENGAGED = 0x06 +MainStateEnum.SETUP_REQUIRED = 0x07 + +MainStateEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(MainStateEnum, new_mt) + +return MainStateEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallCurrentStateStruct.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallCurrentStateStruct.lua new file mode 100644 index 0000000000..cf4aaa91df --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallCurrentStateStruct.lua @@ -0,0 +1,91 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OverallCurrentStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "OverallCurrentStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +OverallCurrentStateStruct.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = true, + is_optional = true, + data_type = require "embedded_clusters.ClosureControl.types.CurrentPositionEnum", + }, + { + name = "latch", + field_id = 1, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, + { + name = "secure_state", + field_id = 3, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Boolean", + }, +} + +OverallCurrentStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +OverallCurrentStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = OverallCurrentStateStruct.init +new_mt.__index.serialize = OverallCurrentStateStruct.serialize + +OverallCurrentStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(OverallCurrentStateStruct, new_mt) + +return OverallCurrentStateStruct diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallTargetStateStruct.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallTargetStateStruct.lua new file mode 100644 index 0000000000..f52fff4d1c --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/OverallTargetStateStruct.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local OverallTargetStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "OverallTargetStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +OverallTargetStateStruct.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = true, + is_optional = true, + data_type = require "embedded_clusters.ClosureControl.types.TargetPositionEnum", + }, + { + name = "latch", + field_id = 1, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +OverallTargetStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +OverallTargetStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = OverallTargetStateStruct.init +new_mt.__index.serialize = OverallTargetStateStruct.serialize + +OverallTargetStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(OverallTargetStateStruct, new_mt) + +return OverallTargetStateStruct diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/TargetPositionEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/TargetPositionEnum.lua new file mode 100644 index 0000000000..b7a6122863 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/TargetPositionEnum.lua @@ -0,0 +1,36 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local TargetPositionEnum = {} +local new_mt = UintABC.new_mt({NAME = "TargetPositionEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.MOVE_TO_FULLY_CLOSED] = "MOVE_TO_FULLY_CLOSED", + [self.MOVE_TO_FULLY_OPEN] = "MOVE_TO_FULLY_OPEN", + [self.MOVE_TO_PEDESTRIAN_POSITION] = "MOVE_TO_PEDESTRIAN_POSITION", + [self.MOVE_TO_VENTILATION_POSITION] = "MOVE_TO_VENTILATION_POSITION", + [self.MOVE_TO_SIGNATURE_POSITION] = "MOVE_TO_SIGNATURE_POSITION", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.MOVE_TO_FULLY_CLOSED = 0x00 +new_mt.__index.MOVE_TO_FULLY_OPEN = 0x01 +new_mt.__index.MOVE_TO_PEDESTRIAN_POSITION = 0x02 +new_mt.__index.MOVE_TO_VENTILATION_POSITION = 0x03 +new_mt.__index.MOVE_TO_SIGNATURE_POSITION = 0x04 + +TargetPositionEnum.MOVE_TO_FULLY_CLOSED = 0x00 +TargetPositionEnum.MOVE_TO_FULLY_OPEN = 0x01 +TargetPositionEnum.MOVE_TO_PEDESTRIAN_POSITION = 0x02 +TargetPositionEnum.MOVE_TO_VENTILATION_POSITION = 0x03 +TargetPositionEnum.MOVE_TO_SIGNATURE_POSITION = 0x04 + +TargetPositionEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(TargetPositionEnum, new_mt) + +return TargetPositionEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/init.lua new file mode 100644 index 0000000000..6531f734a3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureControl/types/init.lua @@ -0,0 +1,10 @@ +local types_mt = {} +types_mt.__index = function(self, key) + return require("embedded_clusters.ClosureControl.types." .. key) +end + +local ClosureControlTypes = {} + +setmetatable(ClosureControlTypes, types_mt) + +return ClosureControlTypes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/init.lua new file mode 100644 index 0000000000..eb2f4cdae2 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/init.lua @@ -0,0 +1,106 @@ +local cluster_base = require "st.matter.cluster_base" +local ClosureDimensionServerAttributes = require "embedded_clusters.ClosureDimension.server.attributes" +local ClosureDimensionServerCommands = require "embedded_clusters.ClosureDimension.server.commands" +local ClosureDimensionTypes = require "embedded_clusters.ClosureDimension.types" + +local ClosureDimension = {} + +ClosureDimension.ID = 0x0105 +ClosureDimension.NAME = "ClosureDimension" +ClosureDimension.server = {} +ClosureDimension.client = {} +ClosureDimension.server.attributes = ClosureDimensionServerAttributes:set_parent_cluster(ClosureDimension) +ClosureDimension.server.commands = ClosureDimensionServerCommands:set_parent_cluster(ClosureDimension) +ClosureDimension.types = ClosureDimensionTypes + +function ClosureDimension:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0000] = "CurrentState", + [0x0001] = "TargetState", + [0x0002] = "Resolution", + [0x0003] = "StepValue", + [0x0004] = "Unit", + [0x0005] = "UnitRange", + [0x0006] = "LimitRange", + [0x0007] = "TranslationDirection", + [0x0008] = "RotationAxis", + [0x0009] = "Overflow", + [0x000A] = "ModulationType", + [0x000B] = "LatchControlModes", + [0xFFF9] = "AcceptedCommandList", + [0xFFFB] = "AttributeList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function ClosureDimension:get_server_command_by_id(command_id) + local server_id_map = { + [0x0000] = "SetTarget", + [0x0001] = "Step", + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +ClosureDimension.attribute_direction_map = { + ["CurrentState"] = "server", + ["TargetState"] = "server", + ["Resolution"] = "server", + ["StepValue"] = "server", + ["Unit"] = "server", + ["UnitRange"] = "server", + ["LimitRange"] = "server", + ["TranslationDirection"] = "server", + ["RotationAxis"] = "server", + ["Overflow"] = "server", + ["ModulationType"] = "server", + ["LatchControlModes"] = "server", + ["AcceptedCommandList"] = "server", + ["AttributeList"] = "server", +} + +ClosureDimension.command_direction_map = { + ["SetTarget"] = "server", + ["Step"] = "server", +} + +ClosureDimension.FeatureMap = ClosureDimension.types.Feature + +function ClosureDimension.are_features_supported(feature, feature_map) + if (ClosureDimension.FeatureMap.bits_are_valid(feature)) then + return (feature & feature_map) == feature + end + return false +end + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = ClosureDimension.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, ClosureDimension.NAME)) + end + return ClosureDimension[direction].attributes[key] +end +ClosureDimension.attributes = {} +setmetatable(ClosureDimension.attributes, attribute_helper_mt) + +local command_helper_mt = {} +command_helper_mt.__index = function(self, key) + local direction = ClosureDimension.command_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown command %s on cluster %s", key, ClosureDimension.NAME)) + end + return ClosureDimension[direction].commands[key] +end +ClosureDimension.commands = {} +setmetatable(ClosureDimension.commands, command_helper_mt) + +setmetatable(ClosureDimension, {__index = cluster_base}) + +return ClosureDimension diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/CurrentState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/CurrentState.lua new file mode 100644 index 0000000000..cd0566b774 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/CurrentState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local CurrentState = { + ID = 0x0000, + NAME = "CurrentState", + base_type = require "embedded_clusters.ClosureDimension.types.DimensionStateStruct", +} + +function CurrentState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function CurrentState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function CurrentState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function CurrentState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function CurrentState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(CurrentState, {__call = CurrentState.new_value, __index = CurrentState.base_type}) +return CurrentState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/LimitRange.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/LimitRange.lua new file mode 100644 index 0000000000..8f9b357bf8 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/LimitRange.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local LimitRange = { + ID = 0x0006, + NAME = "LimitRange", + base_type = require "embedded_clusters.ClosureDimension.types.RangePercent100thsStruct", +} + +function LimitRange:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function LimitRange:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LimitRange:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function LimitRange:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function LimitRange:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function LimitRange:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(LimitRange, {__call = LimitRange.new_value, __index = LimitRange.base_type}) +return LimitRange diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/StepValue.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/StepValue.lua new file mode 100644 index 0000000000..c6958a20bb --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/StepValue.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local StepValue = { + ID = 0x0003, + NAME = "StepValue", + base_type = require "st.matter.data_types.Uint16", +} + +function StepValue:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function StepValue:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function StepValue:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function StepValue:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function StepValue:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function StepValue:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(StepValue, {__call = StepValue.new_value, __index = StepValue.base_type}) +return StepValue diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/TargetState.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/TargetState.lua new file mode 100644 index 0000000000..fa242ca507 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/TargetState.lua @@ -0,0 +1,67 @@ +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local TargetState = { + ID = 0x0001, + NAME = "TargetState", + base_type = require "embedded_clusters.ClosureDimension.types.DimensionStateStruct", +} + +function TargetState:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function TargetState:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TargetState:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TargetState:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function TargetState:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function TargetState:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(TargetState, {__call = TargetState.new_value, __index = TargetState.base_type}) +return TargetState diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/init.lua new file mode 100644 index 0000000000..8728e92157 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/attributes/init.lua @@ -0,0 +1,19 @@ +local attr_mt = {} +attr_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.ClosureDimension.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local ClosureDimensionServerAttributes = {} + +function ClosureDimensionServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ClosureDimensionServerAttributes, attr_mt) + +return ClosureDimensionServerAttributes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/SetTarget.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/SetTarget.lua new file mode 100644 index 0000000000..c14a550f16 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/SetTarget.lua @@ -0,0 +1,112 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local SetTarget = {} + +SetTarget.NAME = "SetTarget" +SetTarget.ID = 0x0000 +SetTarget.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "latch", + field_id = 1, + is_nullable = false, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +function SetTarget:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function SetTarget:init(device, endpoint_id, position, latch, speed) + local out = {} + local args = {position, latch, speed} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = SetTarget, + __tostring = SetTarget.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function SetTarget:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function SetTarget:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function SetTarget:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(SetTarget, {__call = SetTarget.init}) + +return SetTarget diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/Step.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/Step.lua new file mode 100644 index 0000000000..597d2ddd74 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/Step.lua @@ -0,0 +1,112 @@ +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local Step = {} + +Step.NAME = "Step" +Step.ID = 0x0001 +Step.field_defs = { + { + name = "direction", + field_id = 0, + is_nullable = false, + is_optional = false, + data_type = require "embedded_clusters.ClosureDimension.types.StepDirectionEnum", + }, + { + name = "number_of_steps", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +function Step:build_test_command_response(device, endpoint_id, status) + return self._cluster:build_test_command_response( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil, + status + ) +end + +function Step:init(device, endpoint_id, direction, number_of_steps, speed) + local out = {} + local args = {direction, number_of_steps, speed} + if #args > #self.field_defs then + error(self.NAME .. " received too many arguments") + end + for i,v in ipairs(self.field_defs) do + if v.is_optional and args[i] == nil then + out[v.name] = nil + elseif v.is_nullable and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(args[i], data_types.Null, v.name) + out[v.name].field_id = v.field_id + elseif not v.is_optional and args[i] == nil then + out[v.name] = data_types.validate_or_build_type(v.default, v.data_type, v.name) + out[v.name].field_id = v.field_id + else + out[v.name] = data_types.validate_or_build_type(args[i], v.data_type, v.name) + out[v.name].field_id = v.field_id + end + end + setmetatable(out, { + __index = Step, + __tostring = Step.pretty_print + }) + return self._cluster:build_cluster_command( + device, + out, + endpoint_id, + self._cluster.ID, + self.ID, + true + ) +end + +function Step:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function Step:augment_type(base_type_obj) + local elems = {} + for _, v in ipairs(base_type_obj.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + base_type_obj.elements = elems +end + +function Step:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(Step, {__call = Step.init}) + +return Step diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/init.lua new file mode 100644 index 0000000000..74003acc00 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/server/commands/init.lua @@ -0,0 +1,19 @@ +local command_mt = {} +command_mt.__index = function(self, key) + local req_loc = string.format("embedded_clusters.ClosureDimension.server.commands.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + return raw_def +end + +local ClosureDimensionServerCommands = {} + +function ClosureDimensionServerCommands:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(ClosureDimensionServerCommands, command_mt) + +return ClosureDimensionServerCommands diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/DimensionStateStruct.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/DimensionStateStruct.lua new file mode 100644 index 0000000000..ecf718cbf3 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/DimensionStateStruct.lua @@ -0,0 +1,84 @@ +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" + +local DimensionStateStruct = {} +local new_mt = StructureABC.new_mt({NAME = "DimensionStateStruct", ID = data_types.name_to_id_map["Structure"]}) + +DimensionStateStruct.field_defs = { + { + name = "position", + field_id = 0, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "latch", + field_id = 1, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.Boolean", + }, + { + name = "speed", + field_id = 2, + is_nullable = false, + is_optional = true, + data_type = require "embedded_clusters.Global.types.ThreeLevelAutoEnum", + }, +} + +DimensionStateStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for idx, field_def in ipairs(cls.field_defs) do + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + else + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +DimensionStateStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = DimensionStateStruct.init +new_mt.__index.serialize = DimensionStateStruct.serialize + +DimensionStateStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(DimensionStateStruct, new_mt) + +return DimensionStateStruct diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/Feature.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/Feature.lua new file mode 100644 index 0000000000..ecf029105c --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/Feature.lua @@ -0,0 +1,207 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.POSITIONING = 0x0001 +Feature.MOTION_LATCHING = 0x0002 +Feature.UNIT = 0x0004 +Feature.LIMITATION = 0x0008 +Feature.SPEED = 0x0010 +Feature.TRANSLATION = 0x0020 +Feature.ROTATION = 0x0040 +Feature.MODULATION = 0x0080 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + POSITIONING = 0x0001, + MOTION_LATCHING = 0x0002, + UNIT = 0x0004, + LIMITATION = 0x0008, + SPEED = 0x0010, + TRANSLATION = 0x0020, + ROTATION = 0x0040, + MODULATION = 0x0080, +} + +Feature.is_positioning_set = function(self) + return (self.value & self.POSITIONING) ~= 0 +end + +Feature.set_positioning = function(self) + if self.value ~= nil then + self.value = self.value | self.POSITIONING + else + self.value = self.POSITIONING + end +end + +Feature.unset_positioning = function(self) + self.value = self.value & (~self.POSITIONING & self.BASE_MASK) +end + +Feature.is_motion_latching_set = function(self) + return (self.value & self.MOTION_LATCHING) ~= 0 +end + +Feature.set_motion_latching = function(self) + if self.value ~= nil then + self.value = self.value | self.MOTION_LATCHING + else + self.value = self.MOTION_LATCHING + end +end + +Feature.unset_motion_latching = function(self) + self.value = self.value & (~self.MOTION_LATCHING & self.BASE_MASK) +end + +Feature.is_unit_set = function(self) + return (self.value & self.UNIT) ~= 0 +end + +Feature.set_unit = function(self) + if self.value ~= nil then + self.value = self.value | self.UNIT + else + self.value = self.UNIT + end +end + +Feature.unset_unit = function(self) + self.value = self.value & (~self.UNIT & self.BASE_MASK) +end + +Feature.is_limitation_set = function(self) + return (self.value & self.LIMITATION) ~= 0 +end + +Feature.set_limitation = function(self) + if self.value ~= nil then + self.value = self.value | self.LIMITATION + else + self.value = self.LIMITATION + end +end + +Feature.unset_limitation = function(self) + self.value = self.value & (~self.LIMITATION & self.BASE_MASK) +end + +Feature.is_speed_set = function(self) + return (self.value & self.SPEED) ~= 0 +end + +Feature.set_speed = function(self) + if self.value ~= nil then + self.value = self.value | self.SPEED + else + self.value = self.SPEED + end +end + +Feature.unset_speed = function(self) + self.value = self.value & (~self.SPEED & self.BASE_MASK) +end + +Feature.is_translation_set = function(self) + return (self.value & self.TRANSLATION) ~= 0 +end + +Feature.set_translation = function(self) + if self.value ~= nil then + self.value = self.value | self.TRANSLATION + else + self.value = self.TRANSLATION + end +end + +Feature.unset_translation = function(self) + self.value = self.value & (~self.TRANSLATION & self.BASE_MASK) +end + +Feature.is_rotation_set = function(self) + return (self.value & self.ROTATION) ~= 0 +end + +Feature.set_rotation = function(self) + if self.value ~= nil then + self.value = self.value | self.ROTATION + else + self.value = self.ROTATION + end +end + +Feature.unset_rotation = function(self) + self.value = self.value & (~self.ROTATION & self.BASE_MASK) +end + +Feature.is_modulation_set = function(self) + return (self.value & self.MODULATION) ~= 0 +end + +Feature.set_modulation = function(self) + if self.value ~= nil then + self.value = self.value | self.MODULATION + else + self.value = self.MODULATION + end +end + +Feature.unset_modulation = function(self) + self.value = self.value & (~self.MODULATION & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.POSITIONING | + Feature.MOTION_LATCHING | + Feature.UNIT | + Feature.LIMITATION | + Feature.SPEED | + Feature.TRANSLATION | + Feature.ROTATION | + Feature.MODULATION + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_positioning_set = Feature.is_positioning_set, + set_positioning = Feature.set_positioning, + unset_positioning = Feature.unset_positioning, + is_motion_latching_set = Feature.is_motion_latching_set, + set_motion_latching = Feature.set_motion_latching, + unset_motion_latching = Feature.unset_motion_latching, + is_unit_set = Feature.is_unit_set, + set_unit = Feature.set_unit, + unset_unit = Feature.unset_unit, + is_limitation_set = Feature.is_limitation_set, + set_limitation = Feature.set_limitation, + unset_limitation = Feature.unset_limitation, + is_speed_set = Feature.is_speed_set, + set_speed = Feature.set_speed, + unset_speed = Feature.unset_speed, + is_translation_set = Feature.is_translation_set, + set_translation = Feature.set_translation, + unset_translation = Feature.unset_translation, + is_rotation_set = Feature.is_rotation_set, + set_rotation = Feature.set_rotation, + unset_rotation = Feature.unset_rotation, + is_modulation_set = Feature.is_modulation_set, + set_modulation = Feature.set_modulation, + unset_modulation = Feature.unset_modulation, +} + +Feature.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/StepDirectionEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/StepDirectionEnum.lua new file mode 100644 index 0000000000..c33e8a23ef --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/StepDirectionEnum.lua @@ -0,0 +1,27 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local StepDirectionEnum = {} +local new_mt = UintABC.new_mt({NAME = "StepDirectionEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.DECREASE] = "DECREASE", + [self.INCREASE] = "INCREASE", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.DECREASE = 0x00 +new_mt.__index.INCREASE = 0x01 + +StepDirectionEnum.DECREASE = 0x00 +StepDirectionEnum.INCREASE = 0x01 + +StepDirectionEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(StepDirectionEnum, new_mt) + +return StepDirectionEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/TranslationDirectionEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/TranslationDirectionEnum.lua new file mode 100644 index 0000000000..b84a353442 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/TranslationDirectionEnum.lua @@ -0,0 +1,57 @@ +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local TranslationDirectionEnum = {} +local new_mt = UintABC.new_mt({NAME = "TranslationDirectionEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.DOWNWARD] = "DOWNWARD", + [self.UPWARD] = "UPWARD", + [self.VERTICAL_MASK] = "VERTICAL_MASK", + [self.VERTICAL_SYMMETRY] = "VERTICAL_SYMMETRY", + [self.LEFTWARD] = "LEFTWARD", + [self.RIGHTWARD] = "RIGHTWARD", + [self.HORIZONTAL_MASK] = "HORIZONTAL_MASK", + [self.HORIZONTAL_SYMMETRY] = "HORIZONTAL_SYMMETRY", + [self.FORWARD] = "FORWARD", + [self.BACKWARD] = "BACKWARD", + [self.DEPTH_MASK] = "DEPTH_MASK", + [self.DEPTH_SYMMETRY] = "DEPTH_SYMMETRY", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.DOWNWARD = 0x00 +new_mt.__index.UPWARD = 0x01 +new_mt.__index.VERTICAL_MASK = 0x02 +new_mt.__index.VERTICAL_SYMMETRY = 0x03 +new_mt.__index.LEFTWARD = 0x04 +new_mt.__index.RIGHTWARD = 0x05 +new_mt.__index.HORIZONTAL_MASK = 0x06 +new_mt.__index.HORIZONTAL_SYMMETRY = 0x07 +new_mt.__index.FORWARD = 0x08 +new_mt.__index.BACKWARD = 0x09 +new_mt.__index.DEPTH_MASK = 0x0A +new_mt.__index.DEPTH_SYMMETRY = 0x0B + +TranslationDirectionEnum.DOWNWARD = 0x00 +TranslationDirectionEnum.UPWARD = 0x01 +TranslationDirectionEnum.VERTICAL_MASK = 0x02 +TranslationDirectionEnum.VERTICAL_SYMMETRY = 0x03 +TranslationDirectionEnum.LEFTWARD = 0x04 +TranslationDirectionEnum.RIGHTWARD = 0x05 +TranslationDirectionEnum.HORIZONTAL_MASK = 0x06 +TranslationDirectionEnum.HORIZONTAL_SYMMETRY = 0x07 +TranslationDirectionEnum.FORWARD = 0x08 +TranslationDirectionEnum.BACKWARD = 0x09 +TranslationDirectionEnum.DEPTH_MASK = 0x0A +TranslationDirectionEnum.DEPTH_SYMMETRY = 0x0B + +TranslationDirectionEnum.augment_type = function(cls, val) + setmetatable(val, new_mt) +end + +setmetatable(TranslationDirectionEnum, new_mt) + +return TranslationDirectionEnum diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/init.lua new file mode 100644 index 0000000000..8bc2fe2cb2 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/ClosureDimension/types/init.lua @@ -0,0 +1,10 @@ +local types_mt = {} +types_mt.__index = function(self, key) + return require("embedded_clusters.ClosureDimension.types." .. key) +end + +local ClosureDimensionTypes = {} + +setmetatable(ClosureDimensionTypes, types_mt) + +return ClosureDimensionTypes diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/init.lua new file mode 100644 index 0000000000..78739a10db --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/init.lua @@ -0,0 +1,55 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local DescriptorServerAttributes = require "embedded_clusters.Descriptor.server.attributes" + +local Descriptor = {} + +Descriptor.ID = 0x001D +Descriptor.NAME = "Descriptor" +Descriptor.server = {} +Descriptor.client = {} +Descriptor.server.attributes = DescriptorServerAttributes:set_parent_cluster(Descriptor) + +function Descriptor:get_attribute_by_id(attr_id) + local attr_id_map = { + [0x0003] = "PartsList", + [0x0004] = "TagList", + } + local attr_name = attr_id_map[attr_id] + if attr_name ~= nil then + return self.attributes[attr_name] + end + return nil +end + +function Descriptor:get_server_command_by_id(command_id) + local server_id_map = { + } + if server_id_map[command_id] ~= nil then + return self.server.commands[server_id_map[command_id]] + end + return nil +end + +Descriptor.attribute_direction_map = { + ["PartsList"] = "server", + ["TagList"] = "server", +} + +local attribute_helper_mt = {} +attribute_helper_mt.__index = function(self, key) + local direction = Descriptor.attribute_direction_map[key] + if direction == nil then + error(string.format("Referenced unknown attribute %s on cluster %s", key, Descriptor.NAME)) + end + return Descriptor[direction].attributes[key] +end +Descriptor.attributes = {} +setmetatable(Descriptor.attributes, attribute_helper_mt) + +setmetatable(Descriptor, {__index = cluster_base}) + +return Descriptor + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua new file mode 100644 index 0000000000..be83b6f878 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/PartsList.lua @@ -0,0 +1,78 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local PartsList = { + ID = 0x0003, + NAME = "PartsList", + base_type = require "st.matter.data_types.Array", + element_type = require "st.matter.data_types.Uint16", +} + +function PartsList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, PartsList.element_type) + end +end + +function PartsList:new_value(...) + local o = self.base_type(table.unpack({...})) + + return o +end + +function PartsList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PartsList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function PartsList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function PartsList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function PartsList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + + return data +end + +setmetatable(PartsList, {__call = PartsList.new_value, __index = PartsList.base_type}) +return PartsList + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/TagList.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/TagList.lua new file mode 100644 index 0000000000..6bc889d407 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/TagList.lua @@ -0,0 +1,80 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.matter.cluster_base" +local data_types = require "st.matter.data_types" +local TLVParser = require "st.matter.TLV.TLVParser" + +local TagList = { + ID = 0x0004, + NAME = "TagList", + _cluster = require "embedded_clusters.Descriptor", + base_type = require "st.matter.data_types.Array", + element_type = require "embedded_clusters.Global.types.SemanticTagStruct", +} + +function TagList:augment_type(data_type_obj) + for i, v in ipairs(data_type_obj.elements) do + data_type_obj.elements[i] = data_types.validate_or_build_type(v, TagList.element_type) + end +end + +function TagList:new_value(...) + local o = self.base_type(table.unpack({...})) + self:augment_type(o) + return o +end + +function TagList:read(device, endpoint_id) + return cluster_base.read( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + + +function TagList:subscribe(device, endpoint_id) + return cluster_base.subscribe( + device, + endpoint_id, + self._cluster.ID, + self.ID, + nil + ) +end + +function TagList:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +function TagList:build_test_report_data( + device, + endpoint_id, + value, + status +) + local data = data_types.validate_or_build_type(value, self.base_type) + self:augment_type(data) + return cluster_base.build_test_report_data( + device, + endpoint_id, + self._cluster.ID, + self.ID, + data, + status + ) +end + +function TagList:deserialize(tlv_buf) + local data = TLVParser.decode_tlv(tlv_buf) + self:augment_type(data) + return data +end + +setmetatable(TagList, {__call = TagList.new_value, __index = TagList.base_type}) +return TagList + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/init.lua new file mode 100644 index 0000000000..ab933c601f --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/server/attributes/init.lua @@ -0,0 +1,27 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local attr_mt = {} +attr_mt.__attr_cache = {} +attr_mt.__index = function(self, key) + if attr_mt.__attr_cache[key] == nil then + local req_loc = string.format("embedded_clusters.Descriptor.server.attributes.%s", key) + local raw_def = require(req_loc) + local cluster = rawget(self, "_cluster") + raw_def:set_parent_cluster(cluster) + attr_mt.__attr_cache[key] = raw_def + end + return attr_mt.__attr_cache[key] +end + +local DescriptorServerAttributes = {} + +function DescriptorServerAttributes:set_parent_cluster(cluster) + self._cluster = cluster + return self +end + +setmetatable(DescriptorServerAttributes, attr_mt) + +return DescriptorServerAttributes + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/Feature.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/Feature.lua new file mode 100644 index 0000000000..90c03f67b0 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/Feature.lua @@ -0,0 +1,56 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local Feature = {} +local new_mt = UintABC.new_mt({NAME = "Feature", ID = data_types.name_to_id_map["Uint32"]}, 4) + +Feature.BASE_MASK = 0xFFFF +Feature.TAG_LIST = 0x0001 + +Feature.mask_fields = { + BASE_MASK = 0xFFFF, + TAG_LIST = 0x0001, +} + +Feature.is_tag_list_set = function(self) + return (self.value & self.TAG_LIST) ~= 0 +end + +Feature.set_tag_list = function(self) + if self.value ~= nil then + self.value = self.value | self.TAG_LIST + else + self.value = self.TAG_LIST + end +end + +Feature.unset_tag_list = function(self) + self.value = self.value & (~self.TAG_LIST & self.BASE_MASK) +end + +function Feature.bits_are_valid(feature) + local max = + Feature.TAG_LIST + if (feature <= max) and (feature >= 1) then + return true + else + return false + end +end + +Feature.mask_methods = { + is_tag_list_set = Feature.is_tag_list_set, + set_tag_list = Feature.set_tag_list, + unset_tag_list = Feature.unset_tag_list, +} + +Feature.augment_type = function(_cls, val) + setmetatable(val, new_mt) +end + +setmetatable(Feature, new_mt) + +return Feature diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/init.lua new file mode 100644 index 0000000000..e76712b2af --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Descriptor/types/init.lua @@ -0,0 +1,9 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_loader = require "st.utils.lazy_loader" + +local DescriptorTypes = lazy_loader:new { prefix = "embedded_clusters.Descriptor.types." } + +return DescriptorTypes + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/init.lua new file mode 100644 index 0000000000..5c0c673c40 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/init.lua @@ -0,0 +1,8 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local GlobalTypes = require "embedded_clusters.Global.types" + +local Global = {} +Global.types = GlobalTypes +return Global diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/SemanticTagStruct.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/SemanticTagStruct.lua new file mode 100644 index 0000000000..c0e6f8caa7 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/SemanticTagStruct.lua @@ -0,0 +1,94 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local StructureABC = require "st.matter.data_types.base_defs.StructureABC" +local SemanticTagStruct = {} +local new_mt = StructureABC.new_mt({NAME = "SemanticTagStruct", ID = data_types.name_to_id_map["Structure"]}) + +SemanticTagStruct.field_defs = { + { + name = "mfg_code", + field_id = 0, + is_nullable = true, + is_optional = false, + data_type = require "st.matter.data_types.Uint16", + }, + { + name = "namespace_id", + field_id = 1, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "tag", + field_id = 2, + is_nullable = false, + is_optional = false, + data_type = require "st.matter.data_types.Uint8", + }, + { + name = "label", + field_id = 3, + is_nullable = true, + is_optional = true, + data_type = require "st.matter.data_types.UTF8String1", + }, +} + +SemanticTagStruct.init = function(cls, tbl) + local o = {} + o.elements = {} + o.num_elements = 0 + setmetatable(o, new_mt) + for _idx, field_def in ipairs(cls.field_defs) do -- Note: _idx is 1 when field_id is 0 + if (not field_def.is_optional and not field_def.is_nullable) and not tbl[field_def.name] then + error("Missing non optional or non_nullable field: " .. field_def.name) + elseif not (field_def.is_optional and tbl[field_def.name] == nil) then + o.elements[field_def.name] = data_types.validate_or_build_type(tbl[field_def.name], field_def.data_type, field_def.name) + o.elements[field_def.name].field_id = field_def.field_id + o.num_elements = o.num_elements + 1 + end + end + return o +end + +SemanticTagStruct.serialize = function(self, buf, include_control, tag) + return data_types['Structure'].serialize(self.elements, buf, include_control, tag) +end + +new_mt.__call = SemanticTagStruct.init +new_mt.__index.serialize = SemanticTagStruct.serialize + +SemanticTagStruct.augment_type = function(self, val) + local elems = {} + local num_elements = 0 + for _, v in pairs(val.elements) do + for _, field_def in ipairs(self.field_defs) do + if field_def.field_id == v.field_id and + field_def.is_nullable and + (v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, data_types.Null, field_def.field_name) + num_elements = num_elements + 1 + elseif field_def.field_id == v.field_id and not + (field_def.is_optional and v.value == nil and v.elements == nil) then + elems[field_def.name] = data_types.validate_or_build_type(v, field_def.data_type, field_def.field_name) + num_elements = num_elements + 1 + if field_def.element_type ~= nil then + for i, e in ipairs(elems[field_def.name].elements) do + elems[field_def.name].elements[i] = data_types.validate_or_build_type(e, field_def.element_type) + end + end + end + end + end + val.elements = elems + val.num_elements = num_elements + setmetatable(val, new_mt) +end + +setmetatable(SemanticTagStruct, new_mt) + +return SemanticTagStruct + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/ThreeLevelAutoEnum.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/ThreeLevelAutoEnum.lua new file mode 100644 index 0000000000..a203c9b505 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/ThreeLevelAutoEnum.lua @@ -0,0 +1,38 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local data_types = require "st.matter.data_types" +local UintABC = require "st.matter.data_types.base_defs.UintABC" + +local ThreeLevelAutoEnum = {} +local new_mt = UintABC.new_mt({NAME = "ThreeLevelAutoEnum", ID = data_types.name_to_id_map["Uint8"]}, 1) +new_mt.__index.pretty_print = function(self) + local name_lookup = { + [self.AUTO] = "AUTO", + [self.LOW] = "LOW", + [self.MEDIUM] = "MEDIUM", + [self.HIGH] = "HIGH", + } + return string.format("%s: %s", self.field_name or self.NAME, name_lookup[self.value] or string.format("%d", self.value)) +end +new_mt.__tostring = new_mt.__index.pretty_print + +new_mt.__index.AUTO = 0x00 +new_mt.__index.LOW = 0x01 +new_mt.__index.MEDIUM = 0x02 +new_mt.__index.HIGH = 0x03 + +ThreeLevelAutoEnum.AUTO = 0x00 +ThreeLevelAutoEnum.LOW = 0x01 +ThreeLevelAutoEnum.MEDIUM = 0x02 +ThreeLevelAutoEnum.HIGH = 0x03 + +ThreeLevelAutoEnum.augment_type = function(_cls, val) + setmetatable(val, new_mt) +end + +setmetatable(ThreeLevelAutoEnum, new_mt) + + +return ThreeLevelAutoEnum + diff --git a/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/init.lua b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/init.lua new file mode 100644 index 0000000000..f4b0f9b225 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/embedded_clusters/Global/types/init.lua @@ -0,0 +1,18 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local types_mt = {} +types_mt.__types_cache = {} +types_mt.__index = function(self, key) + if types_mt.__types_cache[key] == nil then + types_mt.__types_cache[key] = require("embedded_clusters.Global.types." .. key) + end + return types_mt.__types_cache[key] +end + +local GlobalTypes = {} + +setmetatable(GlobalTypes, types_mt) + + +return GlobalTypes diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index 6759560c50..ba03737a73 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -1,36 +1,140 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --Note: Currently only support for window shades with the PositionallyAware Feature --Note: No support for setting device into calibration mode, it must be done manually local capabilities = require "st.capabilities" local im = require "st.matter.interaction_model" -local log = require "log" local clusters = require "st.matter.clusters" local MatterDriver = require "st.matter.driver" +local utils = require "st.utils" -local CURRENT_LIFT = "__current_lift" -local CURRENT_TILT = "__current_tilt" local battery_support = { NO_BATTERY = "NO_BATTERY", BATTERY_LEVEL = "BATTERY_LEVEL", BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" } -local REVERSE_POLARITY = "__reverse_polarity" -local PRESET_LEVEL_KEY = "__preset_level_key" +local OP_STATUS_BITMAP = { + idle = 0x00, + opening = 0x01, + closing = 0x02 +} local DEFAULT_PRESET_LEVEL = 50 +local TARGET_LIFT_PERCENT = { + TIMEOUT_DELAY_S = 120, + TIMEOUT = "__target_lift_percent_timeout", + STATE = "__target_lift_percent" +} + +local TARGET_TILT_PERCENT = { + TIMEOUT_DELAY_S = 120, + TIMEOUT = "__target_tilt_percent_timeout", + STATE = "__target_tilt_percent" +} + +local TARGET_REACH_TOLERANCE = 0.5 + +--- Gets the current window shade status based on the lift and tilt positions. +---@param device any a Matter device object +---@return any status the current window shade status +local function get_idle_window_shade_status(device) + -- Update the window shade status according to the lift and tilt positions. + -- LIFT TILT Window Shade + -- 100 any Open + -- 1-99 any Partially Open + -- 0 1-100 Partially Open + -- 0 0 Closed + -- 0 nil Closed + -- nil 100 Open + -- nil 1-99 Partially Open + -- nil 0 Closed + -- Note that lift or tilt may be nil if either the window shade does not + -- support them or if they haven't been received from a device report yet. + + local lift_position = device:get_latest_state( + "main", + capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME + ) + local tilt_position = device:get_latest_state( + "main", + capabilities.windowShadeTiltLevel.ID, + capabilities.windowShadeTiltLevel.shadeTiltLevel.NAME + ) + local reverse = device.preferences.reverse + local windowShade = capabilities.windowShade.windowShade + local status_event = windowShade.unknown() + + if lift_position == nil then + if tilt_position == 0 then + status_event = reverse and windowShade.open() or windowShade.closed() + elseif tilt_position == 100 then + status_event = reverse and windowShade.closed() or windowShade.open() + else + status_event = windowShade.partially_open() + end + elseif lift_position == 100 then + status_event = reverse and windowShade.closed() or windowShade.open() + elseif lift_position > 0 then + status_event = windowShade.partially_open() + elseif lift_position == 0 then + if tilt_position == nil or tilt_position == 0 then + status_event = reverse and windowShade.open() or windowShade.closed() + elseif tilt_position > 0 then + status_event = windowShade.partially_open() + end + end + return status_event +end + +--- Clears the cached target percentage and cancels any associated timeout. +--- @param device any a Matter device object +--- @param op_fields table a table containing operational field constants used for caching target percentages and timeouts. +local function clear_cached_data(device, op_fields) + local current_timer = device:get_field(op_fields.TIMEOUT) + if current_timer then + device.thread:cancel_timer(current_timer) + end + device:set_field(op_fields.STATE, nil) + device:set_field(op_fields.TIMEOUT, nil) +end + +--- Tries to emit the idle window shade status if no target percentages are set. +--- @param device any a Matter device object +local function try_emit_idle_window_shade_status(device) + if not device:get_field(TARGET_LIFT_PERCENT.STATE) and + not device:get_field(TARGET_TILT_PERCENT.STATE) then + device:emit_event(get_idle_window_shade_status(device)) + end +end + +--- Caches the target percentage for a device and sets a timeout to clear it. +--- @param device any a Matter device object +--- @param data number the target percentage to cache +--- @param op_fields table a table containing operational field constants used for caching target percentages and timeouts. +local function cache_data_with_timeout(device, data, op_fields) + device:set_field(op_fields.STATE, data) + local previous_timer = device:get_field(op_fields.TIMEOUT) + if previous_timer then + device.thread:cancel_timer(previous_timer) + end + local new_timer = device.thread:call_with_delay(op_fields.TIMEOUT_DELAY_S, function() + device:set_field(op_fields.STATE, nil) + device:set_field(op_fields.TIMEOUT, nil) + try_emit_idle_window_shade_status(device) + end) + device:set_field(op_fields.TIMEOUT, new_timer) +end + +local function is_target_value_reached(current_value, target_value) + if (target_value and math.abs(current_value - target_value) <= TARGET_REACH_TOLERANCE) then + return true + end + return false +end + local function find_default_endpoint(device, cluster) local res = device.MATTER_DEFAULT_ENDPOINT local eps = device:get_endpoints(cluster) @@ -70,17 +174,18 @@ local function match_profile(device, battery_supported) end local function device_init(driver, device) + -- set unused fields to nil. These field settings can be removed after a single driver update. + device:set_field("__preset_level_key", nil) + device:set_field("__reverse_polarity", nil) + device:set_component_to_endpoint_fn(component_to_endpoint) if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then -- These should only ever be nil once (and at the same time) for already-installed devices -- It can be removed after migration is complete device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}})) - local preset_position = device:get_field(PRESET_LEVEL_KEY) or - (device.preferences ~= nil and device.preferences.presetPosition) or - DEFAULT_PRESET_LEVEL + local preset_position = device.preferences.presetPosition or DEFAULT_PRESET_LEVEL device:emit_event(capabilities.windowShadePreset.position(preset_position, {visibility = {displayed = false}})) - device:set_field(PRESET_LEVEL_KEY, preset_position, {persist = true}) end device:subscribe() end @@ -98,17 +203,8 @@ end local function info_changed(driver, device, event, args) if device.profile.id ~= args.old_st_store.profile.id then - -- Profile has changed, resubscribe device:subscribe() - elseif args.old_st_store.preferences.reverse ~= device.preferences.reverse then - if device.preferences.reverse then - device:set_field(REVERSE_POLARITY, true, { persist = true }) - else - device:set_field(REVERSE_POLARITY, false, { persist = true }) - end - else - -- Something else has changed info (SW update, reinterview, etc.), so - -- try updating profile as needed + elseif device.matter_version.software ~= args.old_st_store.matter_version.software then local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) if #battery_feature_eps > 0 then local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) @@ -124,17 +220,18 @@ local function device_added(driver, device) device:emit_event( capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, {visibility = {displayed = false}}) ) - device:set_field(REVERSE_POLARITY, false, { persist = true }) end -local function device_removed(driver, device) log.info("device removed") end - -- capability handlers local function handle_preset(driver, device, cmd) - local lift_value = device:get_latest_state( - "main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME - ) or DEFAULT_PRESET_LEVEL - local hundredths_lift_percent = (100 - lift_value) * 100 + local preset_target_percent = device:get_latest_state( + "main", + capabilities.windowShadePreset.ID, + capabilities.windowShadePreset.position.NAME, + DEFAULT_PRESET_LEVEL + ) + cache_data_with_timeout(device, preset_target_percent, TARGET_LIFT_PERCENT) + local hundredths_lift_percent = (100 - preset_target_percent) * 100 local endpoint_id = device:component_to_endpoint(cmd.component) device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage( device, endpoint_id, hundredths_lift_percent @@ -143,136 +240,157 @@ end local function handle_set_preset(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - device:set_field(PRESET_LEVEL_KEY, cmd.args.position) device:emit_event_for_endpoint(endpoint_id, capabilities.windowShadePreset.position(cmd.args.position)) end --- close covering local function handle_close(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) - if device:get_field(REVERSE_POLARITY) then - req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) > 0 then + cache_data_with_timeout(device, device.preferences.reverse and 100 or 0, TARGET_LIFT_PERCENT) end - device:send(req) + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) > 0 then + cache_data_with_timeout(device, device.preferences.reverse and 100 or 0, TARGET_TILT_PERCENT) + end + device:send(device.preferences.reverse and + clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) or + clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + ) end --- open covering local function handle_open(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) - if device:get_field(REVERSE_POLARITY) then - req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.LIFT}) > 0 then + cache_data_with_timeout(device, device.preferences.reverse and 0 or 100, TARGET_LIFT_PERCENT) end - device:send(req) + if #device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = clusters.WindowCovering.types.Feature.TILT}) > 0 then + cache_data_with_timeout(device, device.preferences.reverse and 0 or 100, TARGET_TILT_PERCENT) + end + device:send(device.preferences.reverse and + clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) or + clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + ) end --- pause covering local function handle_pause(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id) - device:send(req) + device:send(clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id)) end -- move to shade level between 0-100 local function handle_shade_level(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local lift_percentage_value = 100 - cmd.args.shadeLevel - local hundredths_lift_percentage = lift_percentage_value * 100 - local req = clusters.WindowCovering.server.commands.GoToLiftPercentage( - device, endpoint_id, hundredths_lift_percentage - ) - device:send(req) + local hundredths_lift_percentage = (100 - cmd.args.shadeLevel) * 100 + cache_data_with_timeout(device, cmd.args.shadeLevel, TARGET_LIFT_PERCENT) + device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percentage + )) +end + +local function handle_step_shade_level(driver, device, cmd) + local step = cmd.args.stepSize + local latest_lift_percentage = device:get_latest_state( + "main", + capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME, + 0 + ) + local target_lift_percent = device:get_field(TARGET_LIFT_PERCENT.STATE) or latest_lift_percentage + local updated_target_lift = utils.clamp_value(target_lift_percent + step, 0, 100) + cache_data_with_timeout(device, updated_target_lift, TARGET_LIFT_PERCENT) + driver:inject_capability_command(device, { + capability = capabilities.windowShadeLevel.ID, + component = cmd.component, + command = capabilities.windowShadeLevel.commands.setShadeLevel.NAME, + named_args = { shadeLevel = updated_target_lift } + }) end -- move to shade tilt level between 0-100 local function handle_shade_tilt_level(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local tilt_percentage_value = 100 - cmd.args.level - local hundredths_tilt_percentage = tilt_percentage_value * 100 - local req = clusters.WindowCovering.server.commands.GoToTiltPercentage( + local hundredths_tilt_percentage = (100 - cmd.args.level) * 100 + cache_data_with_timeout(device, cmd.args.level, TARGET_TILT_PERCENT) + device:send(clusters.WindowCovering.server.commands.GoToTiltPercentage( device, endpoint_id, hundredths_tilt_percentage - ) - device:send(req) + )) end --- current lift/tilt percentage, changed to 100ths percent -local current_pos_handler = function(attribute) - return function(driver, device, ib, response) - if ib.data.value == nil then - return - end - local windowShade = capabilities.windowShade.windowShade - local position = 100 - math.floor(ib.data.value / 100) - local reverse = device:get_field(REVERSE_POLARITY) - device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) +-- attribute handlers +local function current_position_lift_percent_100ths_handler(driver, device, ib, response) + if not ib.data.value then return end + local lift_percent = 100 - math.floor(ib.data.value / 100) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(lift_percent)) - if attribute == capabilities.windowShadeLevel.shadeLevel then - device:set_field(CURRENT_LIFT, position) + if is_target_value_reached(lift_percent, device:get_field(TARGET_LIFT_PERCENT.STATE)) then + clear_cached_data(device, TARGET_LIFT_PERCENT) + try_emit_idle_window_shade_status(device) + end +end + +local function current_position_tilt_percent_100ths_handler(driver, device, ib, response) + if not ib.data.value then return end + local tilt_percent = 100 - math.floor(ib.data.value / 100) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeTiltLevel.shadeTiltLevel(tilt_percent)) + + if is_target_value_reached(tilt_percent, device:get_field(TARGET_TILT_PERCENT.STATE)) then + clear_cached_data(device, TARGET_TILT_PERCENT) + try_emit_idle_window_shade_status(device) + end +end + +local function target_position_lift_percent_100ths_handler(driver, device, ib, response) + if type(ib.data.value) == "number" then + local target_lift_percent = 100 - math.floor(ib.data.value / 100) + local latest_lift_percentage = device:get_latest_state( + "main", + capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME + ) + if latest_lift_percentage and is_target_value_reached(latest_lift_percentage, target_lift_percent) then + clear_cached_data(device, TARGET_LIFT_PERCENT) + try_emit_idle_window_shade_status(device) else - device:set_field(CURRENT_TILT, position) + cache_data_with_timeout(device, target_lift_percent, TARGET_LIFT_PERCENT) end + end +end - local lift_position = device:get_field(CURRENT_LIFT) - local tilt_position = device:get_field(CURRENT_TILT) - - -- Update the window shade status according to the lift and tilt positions. - -- LIFT TILT Window Shade - -- 100 any Open - -- 1-99 any Partially Open - -- 0 1-100 Partially Open - -- 0 0 Closed - -- 0 nil Closed - -- nil 100 Open - -- nil 1-99 Partially Open - -- nil 0 Closed - -- Note that lift or tilt may be nil if either the window shade does not - -- support them or if they haven't been received from a device report yet. - - if lift_position == nil then - if tilt_position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) - elseif tilt_position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) - else - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) - end - - elseif lift_position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) - - elseif lift_position > 0 then - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) - - elseif lift_position == 0 then - if tilt_position == nil or tilt_position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) - elseif tilt_position > 0 then - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) - end +local function target_position_tilt_percent_100ths_handler(driver, device, ib, response) + if type(ib.data.value) == "number" then + local target_tilt_percent = 100 - math.floor(ib.data.value / 100) + local latest_tilt_percent = device:get_latest_state( + "main", + capabilities.windowShadeTiltLevel.ID, + capabilities.windowShadeTiltLevel.shadeTiltLevel.NAME + ) + if latest_tilt_percent and is_target_value_reached(latest_tilt_percent, target_tilt_percent) then + clear_cached_data(device, TARGET_TILT_PERCENT) + try_emit_idle_window_shade_status(device) + else + cache_data_with_timeout(device, target_tilt_percent, TARGET_TILT_PERCENT) end end end -- checks the current position of the shade -local function current_status_handler(driver, device, ib, response) +local function operational_status_handler(driver, device, ib, response) + if not ib.data.value then return end + local global_op_status = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL + + local reverse = device.preferences.reverse local windowShade = capabilities.windowShade.windowShade - local reverse = device:get_field(REVERSE_POLARITY) - local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL - if state == 1 then -- opening - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closing() or windowShade.opening()) - elseif state == 2 then -- closing - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.opening() or windowShade.closing()) - elseif state ~= 0 then -- unknown - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) + local status_event + if global_op_status == OP_STATUS_BITMAP.idle then + try_emit_idle_window_shade_status(device) + elseif global_op_status == OP_STATUS_BITMAP.opening then + status_event = reverse and windowShade.closing() or windowShade.opening() + elseif global_op_status == OP_STATUS_BITMAP.closing then + status_event = reverse and windowShade.opening() or windowShade.closing() + else + status_event = windowShade.unknown() end -end - -local function level_attr_handler(driver, device, ib, response) - if ib.data.value ~= nil then - --TODO should we invert this like we do for CurrentLiftPercentage100ths? - local level = math.floor((ib.data.value / 254.0 * 100) + 0.5) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(level)) + if status_event then + device:emit_event_for_endpoint(ib.endpoint_id, status_event) end end @@ -293,7 +411,7 @@ local function battery_charge_level_attr_handler(driver, device, ib, response) end local function power_source_attribute_list_handler(driver, device, ib, response) - for _, attr in ipairs(ib.data.elements) do + for _, attr in ipairs(ib.data.elements or {}) do -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) is present. if attr.value == 0x0C then match_profile(device, battery_support.BATTERY_PERCENTAGE) @@ -308,23 +426,18 @@ end local matter_driver_template = { lifecycle_handlers = { init = device_init, - removed = device_removed, added = device_added, infoChanged = info_changed, doConfigure = do_configure }, matter_handlers = { attr = { - --TODO LevelControl may not be needed for certified devices since - -- certified should use CurrentPositionLiftPercent100ths attr - [clusters.LevelControl.ID] = { - [clusters.LevelControl.attributes.CurrentLevel.ID] = level_attr_handler, - }, [clusters.WindowCovering.ID] = { - --uses percent100ths more often - [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler(capabilities.windowShadeLevel.shadeLevel), - [clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths.ID] = current_pos_handler(capabilities.windowShadeTiltLevel.shadeTiltLevel), - [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_position_lift_percent_100ths_handler, + [clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths.ID] = current_position_tilt_percent_100ths_handler, + [clusters.WindowCovering.attributes.TargetPositionLiftPercent100ths.ID] = target_position_lift_percent_100ths_handler, + [clusters.WindowCovering.attributes.TargetPositionTiltPercent100ths.ID] = target_position_tilt_percent_100ths_handler, + [clusters.WindowCovering.attributes.OperationalStatus.ID] = operational_status_handler, }, [clusters.PowerSource.ID] = { [clusters.PowerSource.attributes.AttributeList.ID] = power_source_attribute_list_handler, @@ -340,9 +453,11 @@ local matter_driver_template = { [capabilities.windowShadeLevel.ID] = { clusters.LevelControl.attributes.CurrentLevel, clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths, + clusters.WindowCovering.attributes.TargetPositionLiftPercent100ths, }, [capabilities.windowShadeTiltLevel.ID] = { clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths, + clusters.WindowCovering.attributes.TargetPositionTiltPercent100ths, }, [capabilities.battery.ID] = { clusters.PowerSource.attributes.BatPercentRemaining @@ -364,6 +479,9 @@ local matter_driver_template = { [capabilities.windowShadeLevel.ID] = { [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = handle_shade_level, }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = handle_step_shade_level + }, [capabilities.windowShadeTiltLevel.ID] = { [capabilities.windowShadeTiltLevel.commands.setShadeTiltLevel.NAME] = handle_shade_tilt_level, }, @@ -373,14 +491,14 @@ local matter_driver_template = { capabilities.windowShadeTiltLevel, capabilities.windowShade, capabilities.windowShadePreset, + capabilities.doorControl, + capabilities.level, capabilities.battery, capabilities.batteryLevel, }, - sub_drivers = { - -- for devices sending a position update while device is in motion - require("matter-window-covering-position-updates-while-moving") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } local matter_driver = MatterDriver("matter-window-covering", matter_driver_template) -matter_driver:run() \ No newline at end of file +matter_driver:run() diff --git a/drivers/SmartThings/matter-window-covering/src/lazy_load_subdriver.lua b/drivers/SmartThings/matter-window-covering/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..a04740d267 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/lazy_load_subdriver.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + local MatterDriver = require "st.matter.driver" + local version = require "version" + if version.api >= 16 then + return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return MatterDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/matter-window-covering/src/matter-window-covering-position-updates-while-moving/init.lua b/drivers/SmartThings/matter-window-covering/src/matter-window-covering-position-updates-while-moving/init.lua deleted file mode 100644 index 11ad2d8ef9..0000000000 --- a/drivers/SmartThings/matter-window-covering/src/matter-window-covering-position-updates-while-moving/init.lua +++ /dev/null @@ -1,151 +0,0 @@ --- Copyright 2023 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. - -local capabilities = require "st.capabilities" -local clusters = require "st.matter.clusters" -local device_lib = require "st.device" - -local DEFAULT_LEVEL = 0 -local STATE_MACHINE = "__state_machine" -local REVERSE_POLARITY = "__reverse_polarity" - -local StateMachineEnum = { - STATE_IDLE = 0x00, - STATE_MOVING = 0x01, - STATE_OPERATIONAL_STATE_FIRED = 0x02, - STATE_CURRENT_POSITION_FIRED = 0x03 -} - -local SUB_WINDOW_COVERING_VID_PID = { - {0x10e1, 0x1005} -- VDA -} - -local function is_matter_window_covering_position_updates_while_moving(opts, driver, device) - if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then - return false - end - for i, v in ipairs(SUB_WINDOW_COVERING_VID_PID) do - if device.manufacturer_info.vendor_id == v[1] and - device.manufacturer_info.product_id == v[2] then - return true - end - end - return false -end - -local function device_init(driver, device) - device:subscribe() -end - --- current lift percentage, changed to 100ths percent -local function current_pos_handler(driver, device, ib, response) - if ib.data.value == nil then - return - end - local position = 100 - math.floor(ib.data.value / 100) - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(position)) - local windowShade = capabilities.windowShade.windowShade - local reverse = device:get_field(REVERSE_POLARITY) - local state_machine = device:get_field(STATE_MACHINE) - -- When state_machine is STATE_IDLE or STATE_CURRENT_POSITION_FIRED, nothing to do - if state_machine == StateMachineEnum.STATE_MOVING then - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_CURRENT_POSITION_FIRED) - elseif state_machine == StateMachineEnum.STATE_OPERATIONAL_STATE_FIRED or state_machine == nil then - if position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) - elseif position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) - elseif position > 0 and position < 100 then - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) - else - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) - end - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) - end -end - --- checks the current position of the shade -local function current_status_handler(driver, device, ib, response) - local attr = capabilities.windowShade.windowShade - local position = device:get_latest_state( - "main", capabilities.windowShadeLevel.ID, - capabilities.windowShadeLevel.shadeLevel.NAME - ) or DEFAULT_LEVEL - for _, rb in ipairs(response.info_blocks) do - if rb.info_block.attribute_id == clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID and - rb.info_block.cluster_id == clusters.WindowCovering.ID and - rb.info_block.data ~= nil and - rb.info_block.data.value ~= nil then - position = math.floor(rb.info_block.data.value / 100) - end - end - position = 100 - position - local reverse = device:get_field(REVERSE_POLARITY) - local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL - local state_machine = device:get_field(STATE_MACHINE) - -- When state_machine is STATE_OPERATIONAL_STATE_FIRED, nothing to do - if state_machine == StateMachineEnum.STATE_IDLE then - if state == 1 then -- opening - device:emit_event_for_endpoint(ib.endpoint_id, reverse and attr.closing() or attr.opening()) - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_MOVING) - elseif state == 2 then -- closing - device:emit_event_for_endpoint(ib.endpoint_id, reverse and attr.opening() or attr.closing()) - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_MOVING) - end - elseif state_machine == StateMachineEnum.STATE_MOVING then - if state == 0 then -- not moving - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_OPERATIONAL_STATE_FIRED) - elseif state == 1 then -- opening - device:emit_event_for_endpoint(ib.endpoint_id, reverse and attr.closing() or attr.opening()) - elseif state == 2 then -- closing - device:emit_event_for_endpoint(ib.endpoint_id, reverse and attr.opening() or attr.closing()) - else - device:emit_event_for_endpoint(ib.endpoint_id, attr.unknown()) - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) - end - elseif state_machine == StateMachineEnum.STATE_CURRENT_POSITION_FIRED then - if state == 0 then -- not moving - if position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and attr.closed() or attr.open()) - elseif position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and attr.open() or attr.closed()) - else - device:emit_event_for_endpoint(ib.endpoint_id, attr.partially_open()) - end - else - device:emit_event_for_endpoint(ib.endpoint_id, attr.unknown()) - end - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) - end -end - -local matter_window_covering_position_updates_while_moving_handler = { - NAME = "matter-window-covering-position-updates-while-moving", - lifecycle_handlers = { - init = device_init, - }, - matter_handlers = { - attr = { - [clusters.WindowCovering.ID] = { - [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler, - [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, - } - } - }, - capability_handlers = { - }, - can_handle = is_matter_window_covering_position_updates_while_moving, -} - -return matter_window_covering_position_updates_while_moving_handler diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers.lua new file mode 100644 index 0000000000..3a833f39bc --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("sub_drivers.closure"), +} +return sub_drivers diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/can_handle.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/can_handle.lua new file mode 100644 index 0000000000..0ca3579b75 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CLOSURE_CONTROL_CLUSTER_ID = 0x0104 + +return function(opts, driver, device) + local embedded_cluster_utils = require "sub_drivers.closure.closure_utils.embedded_cluster_utils" + if #embedded_cluster_utils.get_endpoints(device, CLOSURE_CONTROL_CLUSTER_ID) > 0 then + return true, require("sub_drivers.closure") + end + return false +end diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/attribute_handlers.lua new file mode 100644 index 0000000000..24faac339b --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/attribute_handlers.lua @@ -0,0 +1,114 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" + clusters.Global = require "embedded_clusters.Global" +end + +local fields = require "sub_drivers.closure.closure_utils.fields" +local closure_utils = require "sub_drivers.closure.closure_utils.utils" + +local ClosureAttrHandlers = {} + +function ClosureAttrHandlers.main_state_attr_handler(driver, device, ib, response) + if ib.data.value == nil then return end + closure_utils.set_closure_control_state(device, ib.endpoint_id, { main = ib.data.value }) + closure_utils.emit_closure_control_capability(device, ib.endpoint_id) +end + +function ClosureAttrHandlers.overall_current_state_attr_handler(driver, device, ib, response) + if not ib.data.elements then return end + clusters.ClosureControl.types.OverallCurrentStateStruct:augment_type(ib.data) + for _, v in pairs(ib.data.elements or {}) do + if v.field_id == 0 then + local current = v.value + closure_utils.set_closure_control_state(device, ib.endpoint_id, { current = current }) + closure_utils.emit_closure_control_capability(device, ib.endpoint_id) + break + end + end +end + +function ClosureAttrHandlers.overall_target_state_attr_handler(driver, device, ib, response) + if not ib.data.elements then return end + clusters.ClosureControl.types.OverallTargetStateStruct:augment_type(ib.data) + for _, v in pairs(ib.data.elements or {}) do + if v.field_id == 0 then + local target = v.value + closure_utils.set_closure_control_state(device, ib.endpoint_id, { target = target }) + closure_utils.emit_closure_control_capability(device, ib.endpoint_id) + break + end + end +end + +function ClosureAttrHandlers.closure_dimension_current_state_handler(driver, device, ib, response) + if not ib.data.elements then return end + clusters.ClosureDimension.types.DimensionStateStruct:augment_type(ib.data) + local pos_field = ib.data.elements.position + if not pos_field or pos_field.value == nil then return end + local level = math.floor(pos_field.value / 100) + if device:supports_capability_by_id(capabilities.doorControl.ID) then + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.level.level(level)) + else + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(level)) + end +end + +function ClosureAttrHandlers.tag_list_handler(driver, device, ib, response) + if not ib.data.elements then return end + local tag_value + if version.api < 16 then + clusters.Global.types.SemanticTagStruct:augment_type(ib.data) + end + for _, v in ipairs(ib.data.elements) do + local tag = v.elements + if tag and tag.namespace_id and tag.namespace_id.value == 0x44 then + tag_value = tag.tag and tag.tag.value + break + end + end + + local closure_tag_map = { + [0] = fields.closure_tag_list.COVERING, + [1] = fields.closure_tag_list.WINDOW, + [2] = fields.closure_tag_list.BARRIER, + [3] = fields.closure_tag_list.CABINET, + [4] = fields.closure_tag_list.GATE, + [5] = fields.closure_tag_list.GARAGE_DOOR, + [6] = fields.closure_tag_list.DOOR, + } + + local closure_tag = fields.closure_tag_list.NA + if tag_value ~= nil and closure_tag_map[tag_value] ~= nil then + closure_tag = closure_tag_map[tag_value] + end + + device:set_field(fields.CLOSURE_TAG, closure_tag, {persist = true}) + closure_utils.match_profile(device) +end + +function ClosureAttrHandlers.power_source_attribute_list_handler(driver, device, ib, response) + for _, attr in ipairs(ib.data.elements) do + if attr.value == 0x0C then -- BatPercentRemaining + device:set_field(fields.CLOSURE_BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist = true}) + closure_utils.match_profile(device) + return + elseif attr.value == 0x0E then -- BatChargeLevel + device:set_field(fields.CLOSURE_BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist = true}) + closure_utils.match_profile(device) + return + end + end + -- No battery attribute found + device:set_field(fields.CLOSURE_BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + closure_utils.match_profile(device) +end + +return ClosureAttrHandlers diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/capability_handlers.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/capability_handlers.lua new file mode 100644 index 0000000000..4084d0fae1 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_handlers/capability_handlers.lua @@ -0,0 +1,71 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" +end + +local fields = require "sub_drivers.closure.closure_utils.fields" +local closure_utils = require "sub_drivers.closure.closure_utils.utils" + +local ClosureCapabilityHandlers = {} + +-- close covering (or door/gate) +function ClosureCapabilityHandlers.handle_close(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local reverse = device:get_field(fields.REVERSE_POLARITY) + local req = reverse and + clusters.ClosureControl.server.commands.MoveTo( + device, endpoint_id, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN + ) or + clusters.ClosureControl.server.commands.MoveTo( + device, endpoint_id, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED + ) + device:send(req) +end + +-- open covering (or door/gate) +function ClosureCapabilityHandlers.handle_open(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + local reverse = device:get_field(fields.REVERSE_POLARITY) + local req = reverse and + clusters.ClosureControl.server.commands.MoveTo( + device, endpoint_id, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED + ) or + clusters.ClosureControl.server.commands.MoveTo( + device, endpoint_id, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN + ) + device:send(req) +end + +-- pause / stop covering +function ClosureCapabilityHandlers.handle_pause(driver, device, cmd) + local endpoint_id = device:component_to_endpoint(cmd.component) + device:send(clusters.ClosureControl.server.commands.Stop(device, endpoint_id)) +end + +-- move to shade level 0-100 for covering Closure devices +function ClosureCapabilityHandlers.handle_shade_level(driver, device, cmd) + local dim_eps = closure_utils.get_closure_dimension_eps(device) + local endpoint_id = #dim_eps == 1 and dim_eps[1] or device:component_to_endpoint(cmd.component) + if endpoint_id then + device:send(clusters.ClosureDimension.server.commands.SetTarget( + device, endpoint_id, cmd.args.shadeLevel * 100 + )) + end +end + +-- move to level 0-100 for door/gate/garage-door Closure devices +function ClosureCapabilityHandlers.handle_level(driver, device, cmd) + local dim_eps = closure_utils.get_closure_dimension_eps(device) + local endpoint_id = #dim_eps == 1 and dim_eps[1] or device:component_to_endpoint(cmd.component) + device:send(clusters.ClosureDimension.server.commands.SetTarget( + device, endpoint_id, cmd.args.level * 100 + )) +end + +return ClosureCapabilityHandlers diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/embedded_cluster_utils.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/embedded_cluster_utils.lua new file mode 100644 index 0000000000..022f86987a --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/embedded_cluster_utils.lua @@ -0,0 +1,62 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local utils = require "st.utils" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" +end + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +local embedded_cluster_utils = {} + +local embedded_clusters_16 = { + [clusters.Descriptor.ID] = clusters.Descriptor +} + +local embedded_clusters_20 = { + [clusters.ClosureControl.ID] = clusters.ClosureControl, + [clusters.ClosureDimension.ID] = clusters.ClosureDimension +} + +function embedded_cluster_utils.get_endpoints(device, cluster_id, opts) + -- If using older lua libs and need to check for an embedded cluster feature, + -- we must use the embedded cluster definitions here + if (version.api < 16 and embedded_clusters_16[cluster_id] ~= nil) + or (version.api < 20 and embedded_clusters_20[cluster_id] ~= nil) then + local embedded_cluster = embedded_clusters_16[cluster_id] or embedded_clusters_20[cluster_id] + local opts = opts or {} + if utils.table_size(opts) > 1 then + device.log.warn_with({hub_logs = true}, "Invalid options for get_endpoints") + return + end + local clus_has_features = function(clus, feature_bitmap) + if not feature_bitmap or not clus then return false end + return embedded_cluster.are_features_supported(feature_bitmap, clus.feature_map) + end + local eps = {} + for _, ep in ipairs(device.endpoints) do + for _, clus in ipairs(ep.clusters) do + if ((clus.cluster_id == cluster_id) + and (opts.feature_bitmap == nil or clus_has_features(clus, opts.feature_bitmap)) + and ((opts.cluster_type == nil and clus.cluster_type == "SERVER" or clus.cluster_type == "BOTH") + or (opts.cluster_type == clus.cluster_type)) + or (cluster_id == nil)) then + table.insert(eps, ep.endpoint_id) + if cluster_id == nil then break end + end + end + end + return eps + else + return device:get_endpoints(cluster_id, opts) + end + end + + return embedded_cluster_utils diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/fields.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/fields.lua new file mode 100644 index 0000000000..11cd32b8bd --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/fields.lua @@ -0,0 +1,37 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local fields = {} + +fields.REVERSE_POLARITY = "__reverse_polarity" +fields.PRESET_LEVEL_KEY = "__preset_level_key" +fields.DEFAULT_PRESET_LEVEL = 50 + +fields.battery_support = { + NO_BATTERY = "NO_BATTERY", + BATTERY_LEVEL = "BATTERY_LEVEL", + BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE", +} + +fields.CLOSURE_CONTROL_STATE_CACHE = "__closure_control_state_cache" +fields.CLOSURE_BATTERY_SUPPORT = "__closure_battery_support" +fields.CLOSURE_TAG = "__closure_tag" + +fields.closure_tag_list = { + NA = "N/A", + COVERING = "COVERING", + WINDOW = "WINDOW", + BARRIER = "BARRIER", + CABINET = "CABINET", + GATE = "GATE", + GARAGE_DOOR = "GARAGE_DOOR", + DOOR = "DOOR", +} + +-- The maximum number of supported panels for a closure device. Note that this is an +-- arbitrary number and should be raised if needed by a closure device with more panels. +fields.MAX_CLOSURE_PANELS = 4 + +fields.SUBSCRIBED_ATTRIBUTES_KEY = "__subscribed_attributes" + +return fields diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/utils.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/utils.lua new file mode 100644 index 0000000000..8597d029f5 --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/closure_utils/utils.lua @@ -0,0 +1,319 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" +local log = require "log" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" +end + +local fields = require "sub_drivers.closure.closure_utils.fields" +local embedded_cluster_utils = require "sub_drivers.closure.closure_utils.embedded_cluster_utils" + +local utils = {} + +function utils.find_default_endpoint(device, cluster) + local res = device.MATTER_DEFAULT_ENDPOINT + local eps = embedded_cluster_utils.get_endpoints(device, cluster) + table.sort(eps) + for _, v in ipairs(eps) do + if v ~= 0 then -- 0 is the Matter RootNode endpoint + return v + end + end + device.log.warn(string.format( + "Did not find default endpoint, will use endpoint %d instead", + device.MATTER_DEFAULT_ENDPOINT + )) + return res +end + +function utils.get_closure_dimension_eps(device) + local eps = embedded_cluster_utils.get_endpoints(device, clusters.ClosureDimension.ID) or {} + table.sort(eps) + local result = {} + for _, ep in ipairs(eps) do + if ep ~= 0 then + table.insert(result, ep) + if #result >= fields.MAX_CLOSURE_PANELS then break end + end + end + return result +end + +--- Single-panel devices always map to "main"; +--- multi-panel devices map to "windowShade1"..."windowShade4" or "door1"..."door4". +function utils.endpoint_to_component(device, ep_id) + local dim_eps = utils.get_closure_dimension_eps(device) + if #dim_eps > 1 then + local is_door_type = device:supports_capability_by_id(capabilities.doorControl.ID) + local prefix = is_door_type and "door" or "windowShade" + for i, ep in ipairs(dim_eps) do + if ep == ep_id then + return prefix .. i + end + end + end + return "main" +end + +function utils.component_to_endpoint(device, component_name) + local dim_eps = utils.get_closure_dimension_eps(device) + if #dim_eps > 1 then + local comp_num = tonumber(component_name:match("(%d+)$")) + if comp_num and dim_eps[comp_num] then + return dim_eps[comp_num] + end + end + return utils.find_default_endpoint(device, clusters.ClosureControl.ID) +end + +function utils.match_profile(device) + if not device:get_field(fields.CLOSURE_TAG) or not device:get_field(fields.CLOSURE_BATTERY_SUPPORT) then + log.warn("Closure tag or battery support not set yet, cannot match profile") + return + end + + local tag = device:get_field(fields.CLOSURE_TAG) + local profile_name + local is_door_type = true + + if tag == fields.closure_tag_list.GATE then + profile_name = "gate" + elseif tag == fields.closure_tag_list.GARAGE_DOOR then + profile_name = "garage-door" + elseif tag == fields.closure_tag_list.DOOR then + profile_name = "door" + else + -- COVERING, WINDOW, BARRIER, CABINET, NA -> generic covering profile + profile_name = "covering" + is_door_type = false + end + + local optional_caps = {} + local main_component_capabilities = {} + + local closure_battery = device:get_field(fields.CLOSURE_BATTERY_SUPPORT) + if closure_battery == fields.battery_support.BATTERY_PERCENTAGE then + table.insert(main_component_capabilities, capabilities.battery.ID) + elseif closure_battery == fields.battery_support.BATTERY_LEVEL then + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + end + + -- ClosureDimension capabilities: windowShadeLevel (covering) or level (door types) + local dim_eps = utils.get_closure_dimension_eps(device) + if #dim_eps > 0 then + local dim_cap = is_door_type and capabilities.level.ID or capabilities.windowShadeLevel.ID + if #dim_eps == 1 then + -- Single ClosureDimension: enable the capability on the main component. + table.insert(main_component_capabilities, dim_cap) + else + -- Multiple ClosureDimensions: one optional component+capability per panel. + local prefix = is_door_type and "door" or "windowShade" + for i = 1, math.min(#dim_eps, fields.MAX_CLOSURE_PANELS) do + table.insert(optional_caps, {prefix .. i, {dim_cap}}) + end + end + end + + if #main_component_capabilities > 0 then + table.insert(optional_caps, 1, {"main", main_component_capabilities}) + end + + device:try_update_metadata({ + profile = profile_name, + optional_component_capabilities = #optional_caps > 0 and optional_caps or nil, + }) +end + +--- Deeply compare two values. +--- Handles metatables. Can optionally ignore cycle checking and/or function differences. +--- +--- @param a any +--- @param b any +--- @param opts table|nil { ignore_functions = boolean, ignore_cycles = boolean } +--- @param seen table|nil +--- @return boolean +function utils.deep_equals(a, b, opts, seen) + if a == b then return true end + if type(a) ~= type(b) then return false end + if type(a) == "function" and opts and opts.ignore_functions then return true end + if type(a) ~= "table" then return false end + + if not (opts and opts.ignore_cycles) then + seen = seen or {} + seen[a] = seen[a] or {} + if seen[a][b] then + return seen[a][b] + end + seen[a][b] = true + end + + for k, v in pairs(a) do + if not utils.deep_equals(v, b[k], opts, seen) then + return false + end + end + + for k in pairs(b) do + if a[k] == nil then + return false + end + end + + local mt_a = getmetatable(a) + local mt_b = getmetatable(b) + return utils.deep_equals(mt_a, mt_b, opts, seen) +end + +function utils.set_closure_control_state(device, endpoint_id, field) + local cache = device:get_field(fields.CLOSURE_CONTROL_STATE_CACHE) or {} + if not cache[endpoint_id] then cache[endpoint_id] = {} end + for k, v in pairs(field) do + cache[endpoint_id][k] = v + end + device:set_field(fields.CLOSURE_CONTROL_STATE_CACHE, cache) +end + +--- Emits the appropriate windowShade / doorControl capability event from the +--- cached MainState, OverallCurrentState.position, and OverallTargetState.position. +function utils.emit_closure_control_capability(device, endpoint_id) + local cache = device:get_field(fields.CLOSURE_CONTROL_STATE_CACHE) + if not cache then return end + local closure_control_state = cache[endpoint_id] or {} + local reverse = device:get_field(fields.REVERSE_POLARITY) + + local main = closure_control_state.main + local current = closure_control_state.current + local target = closure_control_state.target + + local closure_capability = capabilities.windowShade.windowShade + if device:supports_capability_by_id(capabilities.doorControl.ID) then + closure_capability = capabilities.doorControl.door + end + + if main == clusters.ClosureControl.types.MainStateEnum.MOVING then + if target == clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED then + device:emit_event_for_endpoint( + endpoint_id, reverse and closure_capability.opening() or closure_capability.closing() + ) + elseif target == clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN then + device:emit_event_for_endpoint( + endpoint_id, reverse and closure_capability.closing() or closure_capability.opening() + ) + end + elseif main == clusters.ClosureControl.types.MainStateEnum.STOPPED or main == nil then + if current == nil then return end + if current == clusters.ClosureControl.types.CurrentPositionEnum.FULLY_CLOSED then + device:emit_event_for_endpoint( + endpoint_id, reverse and closure_capability.open() or closure_capability.closed() + ) + elseif current == clusters.ClosureControl.types.CurrentPositionEnum.FULLY_OPENED or + device:supports_capability_by_id(capabilities.doorControl.ID) then + -- doorControl does not support partially_open; treat any non-fully-closed as open + device:emit_event_for_endpoint( + endpoint_id, reverse and closure_capability.closed() or closure_capability.open() + ) + else + device:emit_event_for_endpoint(endpoint_id, closure_capability.partially_open()) + end + end +end + +--- helper for the switch subscribe override, which adds to a subscribed request for a checked device +--- +--- @param checked_device any a Matter device object, either a parent or child device, so not necessarily the same as device +--- @param subscribe_request table a subscribe request that will be appended to as needed for the device +--- @param capabilities_seen table a list of capabilities that have already been checked by previously handled devices +--- @param attributes_seen table a list of attributes that have already been checked +--- @param subscribed_attributes table key-value pairs mapping capability ids to subscribed attributes +function utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, subscribed_attributes) + for _, component in pairs(checked_device.st_store.profile.components) do + for _, capability in pairs(component.capabilities) do + if not capabilities_seen[capability.id] then + for _, attr in ipairs(subscribed_attributes[capability.id] or {}) do + local cluster_id = attr.cluster or attr._cluster.ID + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib + end + end + capabilities_seen[capability.id] = true -- only loop through any capability once + end + end + end +end + +function utils.subscribe(device) + local closure_subscribed_attributes = { + [capabilities.windowShade.ID] = { + clusters.ClosureControl.attributes.MainState, + clusters.ClosureControl.attributes.OverallCurrentState, + clusters.ClosureControl.attributes.OverallTargetState, + }, + [capabilities.doorControl.ID] = { + clusters.ClosureControl.attributes.MainState, + clusters.ClosureControl.attributes.OverallCurrentState, + clusters.ClosureControl.attributes.OverallTargetState, + }, + [capabilities.windowShadeLevel.ID] = { + clusters.ClosureDimension.attributes.CurrentState, + }, + [capabilities.level.ID] = { + clusters.ClosureDimension.attributes.CurrentState, + }, + [capabilities.battery.ID] = { + clusters.PowerSource.attributes.BatPercentRemaining, + }, + [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.BatChargeLevel, + }, + } + + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + local capabilities_seen, attributes_seen = {}, {} + local additional_attributes = {} + + -- The refresh capability command handler in the lua libs uses this key to determine which attributes to read. + device:set_field(fields.SUBSCRIBED_ATTRIBUTES_KEY, attributes_seen) + + -- If the type of battery support has not yet been determined, add the PowerSource AttributeList to the list of + -- subscribed attributes in order to determine which if any battery capability should be used. + if device:get_field(fields.CLOSURE_BATTERY_SUPPORT) == nil then + local ib = im.InteractionInfoBlock(nil, clusters.PowerSource.ID, clusters.PowerSource.attributes.AttributeList.ID) + subscribe_request:with_info_block(ib) + end + + if device:get_field(fields.CLOSURE_TAG) == nil then + table.insert(additional_attributes, clusters.Descriptor.attributes.TagList) + end + + utils.populate_subscribe_request_for_device( + device, subscribe_request, capabilities_seen, attributes_seen, closure_subscribed_attributes + ) + + for _, attr in ipairs(additional_attributes) do + local cluster_id = attr.cluster or attr._cluster.ID + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib + end + end + + if #subscribe_request.info_blocks > 0 then + device:send(subscribe_request) + end +end + +return utils diff --git a/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/init.lua b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/init.lua new file mode 100644 index 0000000000..397167d3df --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/sub_drivers/closure/init.lua @@ -0,0 +1,147 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local log = require "log" +local version = require "version" +local embedded_cluster_utils = require "sub_drivers.closure.closure_utils.embedded_cluster_utils" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" + clusters.Global = require "embedded_clusters.Global" +end + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + + +local fields = require "sub_drivers.closure.closure_utils.fields" +local closure_utils = require "sub_drivers.closure.closure_utils.utils" +local attribute_handlers = require "sub_drivers.closure.closure_handlers.attribute_handlers" +local capability_handlers = require "sub_drivers.closure.closure_handlers.capability_handlers" + +-- --------------------------------------------------------------------------- +-- Lifecycle handlers +-- --------------------------------------------------------------------------- + +local ClosureLifecycleHandlers = {} + +function ClosureLifecycleHandlers.device_init(driver, device) + device:set_component_to_endpoint_fn(closure_utils.component_to_endpoint) + device:set_endpoint_to_component_fn(closure_utils.endpoint_to_component) + if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and + device:get_latest_state("main", capabilities.windowShadePreset.ID, + capabilities.windowShadePreset.position.NAME) == nil then + device:emit_event(capabilities.windowShadePreset.supportedCommands( + {"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}} + )) + local preset_position = device:get_field(fields.PRESET_LEVEL_KEY) or + (device.preferences ~= nil and device.preferences.presetPosition) or + fields.DEFAULT_PRESET_LEVEL + device:emit_event(capabilities.windowShadePreset.position( + preset_position, {visibility = {displayed = false}} + )) + device:set_field(fields.PRESET_LEVEL_KEY, preset_position, {persist = true}) + end + device:extend_device("subscribe", closure_utils.subscribe) + device:subscribe() +end + +function ClosureLifecycleHandlers.device_added(driver, device) + if device:supports_capability_by_id(capabilities.windowShade.ID) then + device:emit_event( + capabilities.windowShade.supportedWindowShadeCommands( + {"open", "close", "pause"}, {visibility = {displayed = false}} + ) + ) + end + device:set_field(fields.REVERSE_POLARITY, false, {persist = true}) +end + +function ClosureLifecycleHandlers.do_configure(driver, device) + if #embedded_cluster_utils.get_endpoints(device, clusters.Descriptor.ID) == 0 then + log.warn( + "Descriptor cluster not implemented on ClosureControl endpoint, " .. + "cannot read TagList to determine closure type" + ) + device:set_field(fields.CLOSURE_TAG, fields.closure_tag_list.NA, {persist = true}) + end + + local battery_feature_eps = device:get_endpoints( + clusters.PowerSource.ID, + {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY} + ) + if #battery_feature_eps == 0 then + device:set_field(fields.CLOSURE_BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + closure_utils.match_profile(device) + end +end + +function ClosureLifecycleHandlers.info_changed(driver, device, event, args) + if not closure_utils.deep_equals( + device.profile, args.old_st_store.profile, {ignore_functions = true} + ) then + device:subscribe() + elseif args.old_st_store.preferences.reverse ~= device.preferences.reverse then + if device.preferences.reverse then + device:set_field(fields.REVERSE_POLARITY, true, {persist = true}) + else + device:set_field(fields.REVERSE_POLARITY, false, {persist = true}) + end + end +end + +-- --------------------------------------------------------------------------- +-- Subdriver template +-- --------------------------------------------------------------------------- + +local closure_handler = { + NAME = "Closure Handler", + lifecycle_handlers = { + init = ClosureLifecycleHandlers.device_init, + added = ClosureLifecycleHandlers.device_added, + doConfigure = ClosureLifecycleHandlers.do_configure, + infoChanged = ClosureLifecycleHandlers.info_changed, + }, + matter_handlers = { + attr = { + [clusters.ClosureControl.ID] = { + [clusters.ClosureControl.attributes.MainState.ID] = attribute_handlers.main_state_attr_handler, + [clusters.ClosureControl.attributes.OverallCurrentState.ID] = attribute_handlers.overall_current_state_attr_handler, + [clusters.ClosureControl.attributes.OverallTargetState.ID] = attribute_handlers.overall_target_state_attr_handler, + }, + [clusters.ClosureDimension.ID] = { + [clusters.ClosureDimension.attributes.CurrentState.ID] = attribute_handlers.closure_dimension_current_state_handler, + }, + [clusters.Descriptor.ID] = { + [clusters.Descriptor.attributes.TagList.ID] = attribute_handlers.tag_list_handler, + }, + [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.AttributeList.ID] = attribute_handlers.power_source_attribute_list_handler, + }, + }, + }, + capability_handlers = { + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.close.NAME] = capability_handlers.handle_close, + [capabilities.windowShade.commands.open.NAME] = capability_handlers.handle_open, + [capabilities.windowShade.commands.pause.NAME] = capability_handlers.handle_pause, + }, + [capabilities.doorControl.ID] = { + [capabilities.doorControl.commands.open.NAME] = capability_handlers.handle_open, + [capabilities.doorControl.commands.close.NAME] = capability_handlers.handle_close, + }, + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = capability_handlers.handle_shade_level, + }, + [capabilities.level.ID] = { + [capabilities.level.commands.setLevel.NAME] = capability_handlers.handle_level, + }, + }, + can_handle = require("sub_drivers.closure.can_handle"), +} + +return closure_handler diff --git a/drivers/SmartThings/matter-window-covering/src/test/test_matter_closure.lua b/drivers/SmartThings/matter-window-covering/src/test/test_matter_closure.lua new file mode 100644 index 0000000000..1c61a6c96a --- /dev/null +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_closure.lua @@ -0,0 +1,865 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local uint32 = require "st.matter.data_types.Uint32" +local version = require "version" + +if version.api < 20 then + clusters.ClosureControl = require "embedded_clusters.ClosureControl" + clusters.ClosureDimension = require "embedded_clusters.ClosureDimension" + clusters.Global = require "embedded_clusters.Global" +end + +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + +-- --------------------------------------------------------------------------- +-- Mock device: Covering type (windowShade / windowShadeLevel) +-- --------------------------------------------------------------------------- + +local mock_device = test.mock_device.build_test_matter_device( + { + label = "Matter Closure", + profile = t_utils.get_profile_definition("covering.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.ClosureControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.Descriptor.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + device_types = { + {device_type_id = 0x0230, device_type_revision = 1} -- Closure + } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.ClosureDimension.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + }, + device_types = { + {device_type_id = 0x0231, device_type_revision = 1} -- ClosureDimension + } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.ClosureDimension.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + }, + device_types = { + {device_type_id = 0x0231, device_type_revision = 1} -- ClosureDimension + } + }, + }, + } +) + +-- --------------------------------------------------------------------------- +-- Mock device: Door type (doorControl / level) +-- --------------------------------------------------------------------------- + +local mock_door_device = test.mock_device.build_test_matter_device( + { + label = "Matter Door", + profile = t_utils.get_profile_definition("door.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.ClosureControl.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 3, + }, + {cluster_id = clusters.Descriptor.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = 0x0002} + }, + device_types = { + {device_type_id = 0x0230, device_type_revision = 1} -- Closure + } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.ClosureDimension.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + }, + device_types = { + {device_type_id = 0x0231, device_type_revision = 1} -- ClosureDimension + } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.ClosureDimension.ID, + cluster_type = "SERVER", + cluster_revision = 1, + feature_map = 0, + }, + }, + device_types = { + {device_type_id = 0x0231, device_type_revision = 1} -- ClosureDimension + } + }, + }, + } +) + +local CLUSTER_SUBSCRIBE_LIST = { + clusters.ClosureControl.attributes.MainState, + clusters.ClosureControl.attributes.OverallCurrentState, + clusters.ClosureControl.attributes.OverallTargetState, +} + +-- additional clusters that will be subscribed to initially but not after the profile is matched. +local ADDITIONAL_SUBSCRIBE_LIST = { + clusters.PowerSource.attributes.AttributeList, + clusters.Descriptor.attributes.TagList, +} + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, + {visibility = {displayed = false}}) + ) + ) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + for _, clus in ipairs(ADDITIONAL_SUBSCRIBE_LIST) do + subscribe_request:merge(clus:subscribe(mock_device)) + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +local function update_profile() + test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_device, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + test.socket.matter:__queue_receive({mock_device.id, clusters.Descriptor.attributes.TagList:build_test_report_data( + mock_device, 10, {clusters.Global.types.SemanticTagStruct({mfg_code = 0x00, namespace_id = 0x44, tag = 0x00, name = "Covering"}) } + )}) + mock_device:expect_metadata_update({ + profile = "covering", + optional_component_capabilities = { + {"main", {"battery"}}, + {"windowShade1", {"windowShadeLevel"}}, + {"windowShade2", {"windowShadeLevel"}}, + } + }) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition("covering.yml", { + enabled_optional_capabilities = { + {"main", {"battery"}}, + {"windowShade1", {"windowShadeLevel"}}, + {"windowShade2", {"windowShadeLevel"}}, + } + }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + subscribe_request:merge(clusters.PowerSource.server.attributes.BatPercentRemaining:subscribe(mock_device)) + subscribe_request:merge(clusters.ClosureDimension.attributes.CurrentState:subscribe(mock_device)) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) +end + +local function test_init_door() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_door_device) + test.socket.device_lifecycle:__queue_receive({ mock_door_device.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_door_device.id, "init" }) + + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_door_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_door_device)) end + end + for _, clus in ipairs(ADDITIONAL_SUBSCRIBE_LIST) do + subscribe_request:merge(clus:subscribe(mock_door_device)) + end + test.socket.matter:__expect_send({mock_door_device.id, subscribe_request}) + + test.socket.device_lifecycle:__queue_receive({ mock_door_device.id, "doConfigure" }) + mock_door_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +local function update_profile_door() + test.socket.matter:__queue_receive({mock_door_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data( + mock_door_device, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} + )}) + test.socket.matter:__queue_receive({mock_door_device.id, clusters.Descriptor.attributes.TagList:build_test_report_data( + mock_door_device, 10, {clusters.Global.types.SemanticTagStruct({mfg_code = 0x00, namespace_id = 0x44, tag = 0x06, name = "Door"})} + )}) + mock_door_device:expect_metadata_update({ + profile = "door", + optional_component_capabilities = { + {"main", {"battery"}}, + {"door1", {"level"}}, + {"door2", {"level"}}, + } + }) + test.wait_for_events() + local updated_device_profile = t_utils.get_profile_definition("door.yml", { + enabled_optional_capabilities = { + {"main", {"battery"}}, + {"door1", {"level"}}, + {"door2", {"level"}}, + } + }) + test.socket.device_lifecycle:__queue_receive(mock_door_device:generate_info_changed({ profile = updated_device_profile })) + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_door_device) + for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_door_device)) end + end + subscribe_request:merge(clusters.PowerSource.server.attributes.BatPercentRemaining:subscribe(mock_door_device)) + subscribe_request:merge(clusters.ClosureDimension.attributes.CurrentState:subscribe(mock_door_device)) + test.socket.matter:__expect_send({mock_door_device.id, subscribe_request}) +end + +test.register_coroutine_test( + "windowShade closed following MainState and OverallTargetState update", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "windowShade opening following MainState and OverallTargetState update", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "windowShade closed following OverallCurrentState FULLY_CLOSED", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "windowShade open following OverallCurrentState FULLY_OPENED", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_OPENED, + latch = true, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "windowShade partially_open following OverallCurrentState PARTIALLY_OPENED", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.PARTIALLY_OPENED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "windowShade state transitions from closing to closed", function() + update_profile() + test.wait_for_events() + -- device starts moving toward closed + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.wait_for_events() + -- device stops and reports fully closed + -- MainState STOPPED with no current position cached yet. no capability event emitted + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_device, 10, clusters.ClosureControl.types.MainStateEnum.STOPPED), + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "windowShade close command sends ClosureControl MoveTo FULLY_CLOSED", function() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShade", component = "main", command = "close", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureControl.server.commands.MoveTo( + mock_device, 10, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED + ) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "windowShade open command sends ClosureControl MoveTo FULLY_OPEN", function() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureControl.server.commands.MoveTo( + mock_device, 10, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN + ) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "windowShade pause command sends ClosureControl Stop", function() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShade", component = "main", command = "pause", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureControl.server.commands.Stop(mock_device, 10) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Battery percentage reported correctly for closure device", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data(mock_device, 10, 150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5))) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "setShadeLevel on windowShade1 sends SetTarget to endpoint 11", function() + update_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "windowShade1", command = "setShadeLevel", args = {75}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureDimension.server.commands.SetTarget(mock_device, 11, 75 * 100) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "setShadeLevel on windowShade2 sends SetTarget to endpoint 12", function() + update_profile() + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "windowShade2", command = "setShadeLevel", args = {40}}, + }) + test.socket.matter:__expect_send({ + mock_device.id, + clusters.ClosureDimension.server.commands.SetTarget(mock_device, 12, 40 * 100) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "ClosureDimension CurrentState on endpoint 11 emits shadeLevel on windowShade1", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_device, 11, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 6000, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("windowShade1", capabilities.windowShadeLevel.shadeLevel(60)) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "ClosureDimension CurrentState on endpoint 12 emits shadeLevel on windowShade2", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_device, 12, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 2500, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("windowShade2", capabilities.windowShadeLevel.shadeLevel(25)) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "ClosureDimension CurrentState with closed position emits shadeLevel 0", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_device, 11, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 0, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("windowShade1", capabilities.windowShadeLevel.shadeLevel(0)) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "ClosureDimension CurrentState with full-open position emits shadeLevel 100", function() + update_profile() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_device, 11, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 10000, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("windowShade1", capabilities.windowShadeLevel.shadeLevel(100)) + ) + end, + { + min_api_version = 15 + } +) + +-- --------------------------------------------------------------------------- +-- Door / garage-door / gate type tests +-- --------------------------------------------------------------------------- + +test.register_coroutine_test( + "doorControl closed following MainState and OverallTargetState update", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_door_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.closing()) + ) + end, + { + test_init = test_init_door, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doorControl opening following MainState and OverallTargetState update", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.MainState:build_test_report_data(mock_door_device, 10, clusters.ClosureControl.types.MainStateEnum.MOVING), + }) + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallTargetState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallTargetStateStruct({ + position = clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM + })) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.opening()) + ) + end, + { + test_init = test_init_door, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doorControl closed following OverallCurrentState FULLY_CLOSED", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_CLOSED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.closed()) + ) + end, + { + test_init = test_init_door, + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "doorControl open following OverallCurrentState FULLY_OPENED", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.FULLY_OPENED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.open()) + ) + end, + { + test_init = test_init_door, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doorControl open following OverallCurrentState PARTIALLY_OPENED (doorControl has no partially_open)", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureControl.attributes.OverallCurrentState:build_test_report_data(mock_door_device, 10, + clusters.ClosureControl.types.OverallCurrentStateStruct({ + position = clusters.ClosureControl.types.CurrentPositionEnum.PARTIALLY_OPENED, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO, + secure_state = false + })) + }) + -- doorControl has no partially_open state; any non-fully-closed position maps to open + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("main", capabilities.doorControl.door.open()) + ) + end, + { + test_init = test_init_door, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "doorControl close command sends ClosureControl MoveTo FULLY_CLOSED", function() + test.socket.capability:__queue_receive({ + mock_door_device.id, + {capability = "doorControl", component = "main", command = "close", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_door_device.id, + clusters.ClosureControl.server.commands.MoveTo( + mock_door_device, 10, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_CLOSED + ) + }) + end, + { + test_init = test_init_door, + min_api_version = 15 + } +) + +test.register_coroutine_test( + "doorControl open command sends ClosureControl MoveTo FULLY_OPEN", function() + test.socket.capability:__queue_receive({ + mock_door_device.id, + {capability = "doorControl", component = "main", command = "open", args = {}}, + }) + test.socket.matter:__expect_send({ + mock_door_device.id, + clusters.ClosureControl.server.commands.MoveTo( + mock_door_device, 10, clusters.ClosureControl.types.TargetPositionEnum.MOVE_TO_FULLY_OPEN + ) + }) + end, + { + test_init = test_init_door, + min_api_version = 15 + } +) +test.register_coroutine_test( + "ClosureDimension CurrentState on endpoint 11 emits level on door1 for door device", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_door_device, 11, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 7500, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("door1", capabilities.level.level(75)) + ) + end, + { + test_init = test_init_door, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "ClosureDimension CurrentState on endpoint 12 emits level on door2 for door device", function() + update_profile_door() + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_door_device.id, + clusters.ClosureDimension.attributes.CurrentState:build_test_report_data(mock_door_device, 12, + clusters.ClosureDimension.types.DimensionStateStruct({ + position = 3000, + latch = false, + speed = clusters.Global.types.ThreeLevelAutoEnum.AUTO + }) + ) + }) + test.socket.capability:__expect_send( + mock_door_device:generate_test_message("door2", capabilities.level.level(30)) + ) + end, + { + test_init = test_init_door, + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua index 409ebfcb09..7199e3704d 100644 --- a/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -18,6 +8,11 @@ local t_utils = require "integration_test.utils" local uint32 = require "st.matter.data_types.Uint32" local clusters = require "st.matter.clusters" +local version = require "version" +if version.api < 16 then + clusters.Descriptor = require "embedded_clusters.Descriptor" +end + local WindowCovering = clusters.WindowCovering test.disable_startup_messages() @@ -26,6 +21,7 @@ local mock_device = test.mock_device.build_test_matter_device( { profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {software = 1}, endpoints = { { endpoint_id = 2, @@ -88,6 +84,8 @@ local CLUSTER_SUBSCRIBE_LIST = { clusters.LevelControl.server.attributes.CurrentLevel, WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, WindowCovering.server.attributes.CurrentPositionTiltPercent100ths, + WindowCovering.server.attributes.TargetPositionLiftPercent100ths, + WindowCovering.server.attributes.TargetPositionTiltPercent100ths, WindowCovering.server.attributes.OperationalStatus, clusters.PowerSource.server.attributes.BatPercentRemaining } @@ -96,6 +94,7 @@ local CLUSTER_SUBSCRIBE_LIST_NO_BATTERY = { clusters.LevelControl.server.attributes.CurrentLevel, WindowCovering.server.attributes.CurrentPositionLiftPercent100ths, WindowCovering.server.attributes.OperationalStatus, + WindowCovering.server.attributes.TargetPositionLiftPercent100ths, } local function set_preset(device) @@ -161,24 +160,18 @@ end test.set_test_init_function(test_init) test.register_coroutine_test( - "WindowCovering OperationalStatus state closed following lift position update", function() - test.socket.capability:__set_channel_ordering("relaxed") + "Idle WindowCovering OperationalStatus state triggers 'open' capability emission if lift position is 100", function() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 10000 + mock_device, 10, 0 ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(0) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closed() + "main", capabilities.windowShadeLevel.shadeLevel(100) ) ) test.socket.matter:__queue_receive( @@ -187,48 +180,19 @@ test.register_coroutine_test( WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) - end -) - -test.register_coroutine_test( - "WindowCovering OperationalStatus state closed following tilt position update", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, 10, 10000 - ), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) - ) - ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closed() + "main", capabilities.windowShade.windowShade.open() ) ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), - } - ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus state closed before lift position 0", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), - } - ) + "Idle WindowCovering OperationalStatus state triggers 'closed' capability emission if lift position is 0", function() test.socket.matter:__queue_receive( { mock_device.id, @@ -242,63 +206,36 @@ test.register_coroutine_test( "main", capabilities.windowShadeLevel.shadeLevel(0) ) ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closed() - ) - ) - end -) - -test.register_coroutine_test( - "WindowCovering OperationalStatus state closed before tilt position 0", function() - test.socket.capability:__set_channel_ordering("relaxed") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, 10, 10000 - ), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) - ) - ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShade.windowShade.closed() ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus state open following lift position update", function() - test.socket.capability:__set_channel_ordering("relaxed") + "Idle WindowCovering OperationalStatus state triggers 'partially open' capability emission if lift position is 50", function() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 0 + mock_device, 10, 5000 ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(100) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.open() + "main", capabilities.windowShadeLevel.shadeLevel(50) ) ) test.socket.matter:__queue_receive( @@ -307,28 +244,31 @@ test.register_coroutine_test( WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus state open following tilt position update", function() + "Idle WindowCovering OperationalStatus state triggers 'partially open' capability emission if tilt position is between 1-100", function() test.socket.capability:__set_channel_ordering("relaxed") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, 10, 0 + mock_device, 10, ((100 - 15) *100) ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.open() + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) ) ) test.socket.matter:__queue_receive( @@ -337,72 +277,103 @@ test.register_coroutine_test( WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus state open before lift position event", function() - test.socket.capability:__set_channel_ordering("relaxed") + "Unknown WindowCovering OperationalStatus state triggers 'unknown' capability emission", function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 99), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(100) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.open() + "main", capabilities.windowShade.windowShade.unknown() ) ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Opening WindowCovering OperationalStatus state triggers 'closing' capability emission", function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 0 - ), + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 2), } ) - end + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closing() + ) + ) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus state open before tilt position event", function() - test.socket.capability:__set_channel_ordering("relaxed") + "Closing WindowCovering OperationalStatus state triggers 'opening' capability emission", function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 1), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + "main", capabilities.windowShade.windowShade.opening() ) ) + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "WindowCovering CurrentPositionLiftPercent100ths triggers appropriate capability emission", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 10000 + ), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.open() + "main", capabilities.windowShadeLevel.shadeLevel(0) ) ) test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( mock_device, 10, 0 ), } ) - end -) - -test.register_coroutine_test( - "WindowCovering OperationalStatus partially open following lift position update", function() - test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) test.socket.matter:__queue_receive( { mock_device.id, @@ -416,23 +387,40 @@ test.register_coroutine_test( "main", capabilities.windowShadeLevel.shadeLevel(25) ) ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering CurrentPositionTiltPercent100ths triggers appropriate capability emission", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 10000 + ), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) ) ) test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), } ) - end -) - -test.register_coroutine_test( - "WindowCovering OperationalStatus partially open following tilt position update", function() - test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) test.socket.matter:__queue_receive( { mock_device.id, @@ -446,222 +434,316 @@ test.register_coroutine_test( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(15) ) ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 65) *100) + ), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) ) ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering TargetPositionLiftPercent100ths sets field appropriately", + function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.TargetPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), } ) - end + test.wait_for_events() + assert(mock_device:get_field("__target_lift_percent") == 50) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus partially open before lift position event", function() - test.socket.capability:__set_channel_ordering("relaxed") + "WindowCovering TargetPositionTiltPercent100ths sets field appropriately", + function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.TargetPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), } ) + test.wait_for_events() + assert(mock_device:get_field("__target_tilt_percent") == 50) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "WindowCovering TargetPositionLiftPercent100ths triggers operational status update when appropriate", + function() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) + mock_device, 10, ((100 - 65) *100) ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) + "main", capabilities.windowShadeLevel.shadeLevel(65) ) ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ), + } + ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShade.windowShade.partially_open() ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering OperationalStatus partially open before tilt position event", function() - test.socket.capability:__set_channel_ordering("relaxed") + "WindowCovering TargetPositionTiltPercent100ths triggers operational status update when appropriate", + function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, ((100 - 65) *100) + ), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) + ) + ) test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 65) *100) + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 ), } ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(65) - ) - ) test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShade.windowShade.partially_open() ) ) - end + end, + { + min_api_version = 17 + } ) -test.register_coroutine_test("WindowCovering OperationalStatus opening", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() +test.register_coroutine_test( + "Open capability command triggers appropriate Matter command", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShade", component = "main", command = "open", args = {}}, + } ) - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 1), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.opening() + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 10)} ) - ) -end) + end, + { + min_api_version = 17 + } +) -test.register_coroutine_test("WindowCovering OperationalStatus closing", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() +test.register_coroutine_test( + "Close capability command triggers appropriate Matter command", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShade", component = "main", command = "close", args = {}}, + } ) - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 2), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closing() + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.DownOrClose(mock_device, 10)} ) - ) -end) + end, + { + min_api_version = 17 + } +) -test.register_coroutine_test("WindowCovering OperationalStatus unknown", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() +test.register_coroutine_test( + "Pause capability command triggers appropriate Matter command", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShade", component = "main", command = "pause", args = {}}, + } ) - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 3), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.unknown() + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.StopMotion(mock_device, 10)} ) - ) -end) + end, + { + min_api_version = 17 + } +) test.register_coroutine_test( - "WindowShade open cmd handler", function() + "Cached target lift/tilt position timeouts trigger one operational status update", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 50 * 100 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(50) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) test.socket.capability:__queue_receive( { mock_device.id, {capability = "windowShade", component = "main", command = "open", args = {}}, } ) + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 10)} ) + test.wait_for_events() - end + test.mock_time.advance_time(120) + + test.socket.capability:__expect_send({ + mock_device.id, { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } }, + } + ) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowShade close cmd handler", function() + "Cached target lift timeout triggers one operational status update if tilt target and idle state are first reached appropriately", + function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(50) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) test.socket.capability:__queue_receive( { mock_device.id, - {capability = "windowShade", component = "main", command = "close", args = {}}, + {capability = "windowShade", component = "main", command = "open", args = {}}, } ) + -- two timers are made when open is called + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") test.socket.matter:__expect_send( - {mock_device.id, WindowCovering.server.commands.DownOrClose(mock_device, 10)} + {mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 10)} ) - test.wait_for_events() - end -) -test.register_coroutine_test( - "WindowShade pause cmd handler", function() - test.socket.capability:__queue_receive( + -- ensure target state is cached before queueing responses + test.wait_for_events() + test.socket.matter:__queue_receive( { mock_device.id, - {capability = "windowShade", component = "main", command = "pause", args = {}}, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), } ) - test.socket.matter:__expect_send( - {mock_device.id, WindowCovering.server.commands.StopMotion(mock_device, 10)} + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) + ) + ) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ), + } ) + + -- advance time to trigger the single remaining timer test.wait_for_events() - end + test.mock_time.advance_time(120) + test.socket.capability:__expect_send({ + mock_device.id, { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } }, + } + ) + + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -690,10 +772,13 @@ test.register_coroutine_test( end test.socket.matter:__expect_send({mock_device.id, read_request}) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) -test.register_coroutine_test("WindowShade setShadeLevel cmd handler", function() +test.register_coroutine_test("SetShadeLevel capability command triggers appropriate Matter command", function() test.socket.capability:__queue_receive( { mock_device.id, @@ -703,9 +788,13 @@ test.register_coroutine_test("WindowShade setShadeLevel cmd handler", function() test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 8000)} ) -end) +end, +{ + min_api_version = 17 +} +) -test.register_coroutine_test("WindowShade setShadeTiltLevel cmd handler", function() +test.register_coroutine_test("SetShadeTiltLevel capability command triggers appropriate Matter command", function() test.socket.capability:__queue_receive( { mock_device.id, @@ -715,21 +804,11 @@ test.register_coroutine_test("WindowShade setShadeTiltLevel cmd handler", functi test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 4000)} ) -end) - -test.register_coroutine_test("LevelControl CurrentLevel handler", function() - test.socket.matter:__queue_receive( - { - mock_device.id, - clusters.LevelControl.attributes.CurrentLevel:build_test_report_data(mock_device, 10, 100), - } - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(math.floor((100 / 254.0 * 100) + .5)) - ) - ) -end) +end, +{ + min_api_version = 17 +} +) --test battery test.register_coroutine_test( @@ -747,28 +826,12 @@ test.register_coroutine_test( "main", capabilities.battery.battery(math.floor(150/2.0+0.5)) ) ) - end + end, + { + min_api_version = 17 + } ) -test.register_coroutine_test("OperationalStatus report contains current position report", function() - test.socket.capability:__set_channel_ordering("relaxed") - local report = WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ) - table.insert(report.info_blocks, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0).info_blocks[1]) - test.socket.matter:__queue_receive({ mock_device.id, report}) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() - ) - ) -end) - test.register_coroutine_test( "Handle preset commands", function() @@ -789,7 +852,10 @@ test.register_coroutine_test( test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, (100 - PRESET_LEVEL) * 100)} ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -802,7 +868,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ profile = "window-covering-tilt-battery" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -814,198 +883,306 @@ test.register_coroutine_test( clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(10)}) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( "Test mains powered device does not switch to battery profile", function() end, - { test_init = test_init_mains_powered } + { + test_init = test_init_mains_powered, + min_api_version = 17 + } ) test.register_coroutine_test( - "InfoChanged event checks for new profile match if device has changed (i.e. through reinterview or SW update)", + "InfoChanged event updates new profile if sw updated has occurred", function() - test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ matter_version = {software = 2} })) local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) test.wait_for_events() test.socket.matter:__queue_receive({mock_device.id, clusters.PowerSource.attributes.AttributeList:build_test_report_data(mock_device, 10, {uint32(0x0C)})}) mock_device:expect_metadata_update({profile = "window-covering-tilt-battery"}) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "WindowCovering shade level adjusted by greater than 2%; status reflects Closing followed by Partially Open", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), - } - ) - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) - ), - } - ) + "Check that preference updates to reverse polarity after being set to true and that the shade lift operates as expected when opening and closing", function() + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 100 * 100 + ) + }) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) + "main", capabilities.windowShadeLevel.shadeLevel(0) ) ) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ) + }) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShade.windowShade.open() ) ) test.wait_for_events() - test.socket.capability:__queue_receive( - { - mock_device.id, - {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 19 }}, - } - ) - test.socket.matter:__expect_send( - {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 8100)} + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) ) test.wait_for_events() - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 10), - } - ) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ) + }) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closing() + "main", capabilities.windowShade.windowShade.closed() ) ) test.wait_for_events() - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 23) *100) - ), - } + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 85 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 1500)} + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 100 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 0)} ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Check that preference updates to reverse polarity after being set to true and that the shade tilt operates as expected when opening and closing", function() + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) + test.wait_for_events() + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 100 * 100 + ) + }) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(23) + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) ) ) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ) + }) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShade.windowShade.open() ) ) - test.wait_for_events() - test.socket.matter:__queue_receive( - { - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 21) *100) - ), - } - ) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( + mock_device, 10, 0 + ) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data( + mock_device, 10, 0 + ) + }) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(21) + "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) ) ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShade.windowShade.closed() ) ) test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 15 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 8500)} + ) + test.socket.capability:__queue_receive({ + mock_device.id, + {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 0 }}, + }) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 10000)} + ) + end, + { + min_api_version = 17 + } +) + +-- stepShadeLevel tests + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - step up", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 9000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - step down", function() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 19) *100) + mock_device, 10, 5000 ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(19) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShadeLevel.shadeLevel(50) ) ) - end -) - -test.register_coroutine_test( - "WindowCovering shade level adjusted by less than or equal to 2%; status reflects Closing followed by Partially Open", function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.matter:__queue_receive( + test.wait_for_events() + test.socket.capability:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 }}, } ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 7000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - continuous step with target tracking", function() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 25) *100) + mock_device, 10, 7000 ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() + "main", capabilities.windowShadeLevel.shadeLevel(30) ) ) test.wait_for_events() test.socket.capability:__queue_receive( { mock_device.id, - {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 23 }}, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, } ) test.socket.matter:__expect_send( - {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 7700)} + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 6000)} ) test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 5000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - target reached clears target marker", function() test.socket.matter:__queue_receive( { mock_device.id, - WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 10), + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closing() + "main", capabilities.windowShadeLevel.shadeLevel(50) ) ) test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 4000)} + ) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, ((100 - 23) *100) + mock_device, 10, 4000 ), } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(23) + "main", capabilities.windowShadeLevel.shadeLevel(60) ) ) test.socket.capability:__expect_send( @@ -1013,117 +1190,72 @@ test.register_coroutine_test( "main", capabilities.windowShade.windowShade.partially_open() ) ) - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( - "Check that preference updates to reverse polarity after being set to true and that the shade lift operates as expected when opening and closing", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) - test.wait_for_events() - local reverse_preference_set = mock_device:get_field("__reverse_polarity") - assert(reverse_preference_set == true, "reverse_preference_set is True") - test.socket.matter:__queue_receive({ - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 100 * 100 - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(0) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.open() - ) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - mock_device, 10, 0 - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(100) - ) + "WindowShade stepShadeLevel - step up to maximum (100)", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 500 + ), + } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closed() + "main", capabilities.windowShadeLevel.shadeLevel(95) ) ) - test.socket.capability:__queue_receive({ - mock_device.id, - {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 85 }}, - }) - test.socket.matter:__expect_send( - {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 1500)} + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } ) - test.socket.capability:__queue_receive({ - mock_device.id, - {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 100 }}, - }) test.socket.matter:__expect_send( {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 0)} ) - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( - "Check that preference updates to reverse polarity after being set to true and that the shade tilt operates as expected when opening and closing", function() - test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) - test.wait_for_events() - local reverse_preference_set = mock_device:get_field("__reverse_polarity") - assert(reverse_preference_set == true, "reverse_preference_set is True") - test.socket.matter:__queue_receive({ - mock_device.id, - WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, 10, 100 * 100 - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(0) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.open() - ) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data( - mock_device, 10, 0 - ) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) - ) + "WindowShade stepShadeLevel - step down to minimum (0)", function() + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 9500 + ), + } ) test.socket.capability:__expect_send( mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.closed() + "main", capabilities.windowShadeLevel.shadeLevel(5) ) ) - test.socket.capability:__queue_receive({ - mock_device.id, - {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 15 }}, - }) - test.socket.matter:__expect_send( - {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 8500)} + test.wait_for_events() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -10 }}, + } ) - test.socket.capability:__queue_receive({ - mock_device.id, - {capability = "windowShadeTiltLevel", component = "main", command = "setShadeTiltLevel", args = { 0 }}, - }) test.socket.matter:__expect_send( - {mock_device.id, WindowCovering.server.commands.GoToTiltPercentage(mock_device, 10, 10000)} + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 10000)} ) - end + end, + { + min_api_version = 19 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/profiles/legacy-color.yml b/drivers/SmartThings/philips-hue/profiles/legacy-color.yml index fa3adedb6d..2d906dab24 100644 --- a/drivers/SmartThings/philips-hue/profiles/legacy-color.yml +++ b/drivers/SmartThings/philips-hue/profiles/legacy-color.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorControl version: 1 - id: samsungim.hueSyncMode diff --git a/drivers/SmartThings/philips-hue/profiles/white-ambiance.yml b/drivers/SmartThings/philips-hue/profiles/white-ambiance.yml index b7c6efc7eb..354b8bbc2e 100644 --- a/drivers/SmartThings/philips-hue/profiles/white-ambiance.yml +++ b/drivers/SmartThings/philips-hue/profiles/white-ambiance.yml @@ -6,8 +6,12 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 + - id: statelessColorTemperatureStep + version: 1 - id: samsungim.hueSyncMode version: 1 - id: refresh diff --git a/drivers/SmartThings/philips-hue/profiles/white-and-color-ambiance.yml b/drivers/SmartThings/philips-hue/profiles/white-and-color-ambiance.yml index 35fa5550bd..7fe8797be2 100644 --- a/drivers/SmartThings/philips-hue/profiles/white-and-color-ambiance.yml +++ b/drivers/SmartThings/philips-hue/profiles/white-and-color-ambiance.yml @@ -6,10 +6,14 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorControl version: 1 - id: colorTemperature version: 1 + - id: statelessColorTemperatureStep + version: 1 - id: samsungim.hueSyncMode version: 1 - id: refresh diff --git a/drivers/SmartThings/philips-hue/profiles/white.yml b/drivers/SmartThings/philips-hue/profiles/white.yml index 447ddcad81..18312c48e2 100644 --- a/drivers/SmartThings/philips-hue/profiles/white.yml +++ b/drivers/SmartThings/philips-hue/profiles/white.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: samsungim.hueSyncMode version: 1 - id: refresh diff --git a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua index f274ef37f0..05c9dbb7e9 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua @@ -158,6 +158,19 @@ local function _emit_light_events_inner(light_device, light_repr) end end +-- A sufficiently small color temp command can transform to the same value (in mireks) that a device already has, so the bridge +-- may not respond to said command. To avoid any odd behaviors in this case, we should emit a kelvin value change event immediately +function AttributeEmitters.emit_color_temp_when_mirek_unchanged(device, clamped_kelvin, mirek) + local current_color_temp = device:get_latest_state("main", capabilities.colorTemperature.ID, capabilities.colorTemperature.colorTemperature.NAME) + if current_color_temp then + local current_mirek = math.floor(utils.kelvin_to_mirek(current_color_temp)) + if current_mirek == mirek then + log.debug(string.format("Color temp change from %dK to %dK results in same mirek value (%d), emitting event directly", current_color_temp, clamped_kelvin, mirek)) + device:emit_event(capabilities.colorTemperature.colorTemperature(clamped_kelvin)) + end + end +end + function AttributeEmitters.connectivity_update(child_device, zigbee_status) if child_device == nil or (child_device and child_device.id == nil) then log.warn("Tried to emit attribute events for a device that has been deleted") diff --git a/drivers/SmartThings/philips-hue/src/handlers/commands.lua b/drivers/SmartThings/philips-hue/src/handlers/commands.lua index cf30834a2c..480f003769 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/commands.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/commands.lua @@ -7,6 +7,7 @@ st_utils.stringify_table = st_utils.stringify_table local Consts = require "consts" local Fields = require "fields" local HueColorUtils = require "utils.cie_utils" +local attribute_emitters = require "handlers.attribute_emitters" local utils = require "utils" @@ -19,9 +20,7 @@ local CommandHandlers = {} ---@param driver HueDriver ---@param device HueChildDevice ----@param args table -local function do_switch_action(driver, device, args) - local on = args.command == "on" +local function get_light_device_id_and_hue_api_module(driver, device) local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) @@ -49,15 +48,21 @@ local function do_switch_action(driver, device, args) return end - local resp, err = hue_api:set_light_on_state(light_id, on) + return light_id, hue_api +end + +---@param response table? Command response from the Hue API, expected to have an 'errors' field if there were issues +---@param err string? Error message returned from the Hue API call, if any +---@param action_desc string Description of the action being performed, for logging purposes +local function log_command_response_errors(response, err, action_desc) + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing " .. action_desc .. ": " .. err) + end - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end + if response and response.errors and #response.errors > 0 then + for _, error in ipairs(response.errors) do + log.error_with({ hub_logs = true }, + "Error returned in Hue response for " .. action_desc .. ": " .. error.description) end end end @@ -65,60 +70,30 @@ end ---@param driver HueDriver ---@param device HueChildDevice ---@param args table -local function do_switch_level_action(driver, device, args) - local level = st_utils.clamp_value(args.args.level, 1, 100) - local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) - - if not bridge_device then - log.warn( - "Couldn't get a bridge for light with Child Key " .. - (device.parent_assigned_child_key or "unexpected nil parent_assigned_child_key")) - return - end +local function do_switch_action(driver, device, args) + local on = args.command == "on" + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end - local light_id = utils.get_hue_rid(device) - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] + local resp, err = hue_api:set_light_on_state(light_id, on) + log_command_response_errors(resp, err, "on/off action") +end - if not (light_id and hue_api) then - log.warn( - string.format( - "Could not get a proper light resource ID or API instance for %s" .. - "\n\tLight Resource ID: %s" .. - "\n\tHue API nil? %s", - (device.label or device.id or "unknown device"), - light_id, - (hue_api == nil) - ) - ) - return - end +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_switch_level_action(driver, device, args) + local level = st_utils.clamp_value(args.args.level, 1, 100) + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end local is_off = device:get_field(Fields.SWITCH_STATE) == "off" - if is_off then local resp, err = hue_api:set_light_on_state(light_id, true) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end + log_command_response_errors(resp, err, "on/off action") end - local resp, err = hue_api:set_light_level(light_id, level) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing switch level action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end + log_command_response_errors(resp, err, "switch level action") end ---@param driver HueDriver @@ -130,46 +105,13 @@ local function do_color_action(driver, device, args) hue = 0 device:set_field(Fields.WRAPPED_HUE, true) end - local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) - - if not bridge_device then - log.warn( - "Couldn't get a bridge for light with Child Key " .. - (device.parent_assigned_child_key or "unexpected nil parent_assigned_child_key")) - return - end - - local light_id = utils.get_hue_rid(device) - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] - - if not (light_id and hue_api) then - log.warn( - string.format( - "Could not get a proper light resource ID or API instance for %s" .. - "\n\tLight Resource ID: %s" .. - "\n\tHue API nil? %s", - (device.label or device.id or "unknown device"), - light_id, - (hue_api == nil) - ) - ) - return - end + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end local red, green, blue = st_utils.hsv_to_rgb(hue, sat) local xy = HueColorUtils.safe_rgb_to_xy(red, green, blue, device:get_field(Fields.GAMUT)) - local resp, err = hue_api:set_light_color_xy(light_id, xy) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing color action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end + log_command_response_errors(resp, err, "color action") end -- Function to allow changes to "setHue" attribute to Philips Hue light devices @@ -207,51 +149,59 @@ end ---@param args table local function do_color_temp_action(driver, device, args) local kelvin = args.args.temperature - local id = device.parent_device_id or device:get_field(Fields.PARENT_DEVICE_ID) - local bridge_device = utils.get_hue_bridge_for_device(driver, device, id) - - if not bridge_device then - log.warn( - "Couldn't get a bridge for light with Child Key " .. - (device.parent_assigned_child_key or "unexpected nil parent_assigned_child_key")) - return - end - - local light_id = utils.get_hue_rid(device) - local hue_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] - - if not (light_id and hue_api) then - log.warn( - string.format( - "Could not get a proper light resource ID or API instance for %s" .. - "\n\tLight Resource ID: %s" .. - "\n\tHue API nil? %s", - (device.label or device.id or "unknown device"), - light_id, - (hue_api == nil) - ) - ) - return - end + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end local min = device:get_field(Fields.MIN_KELVIN) or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE local clamped_kelvin = st_utils.clamp_value(kelvin, min, Consts.MAX_TEMP_KELVIN) local mirek = math.floor(utils.kelvin_to_mirek(clamped_kelvin)) + attribute_emitters.emit_color_temp_when_mirek_unchanged(device, clamped_kelvin, mirek) local resp, err = hue_api:set_light_color_temp(light_id, mirek) - - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing color temp action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) - end - end - end + log_command_response_errors(resp, err, "color temp action") device:set_field(Fields.COLOR_TEMP_SETPOINT, clamped_kelvin); end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_step_level_action(driver, device, args) + local step_percent = args.args and args.args.stepSize or 0 + if step_percent == 0 then return end + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end + + -- stepSize is already in percent; Hue brightness_delta is also in percent + local action = (step_percent > 0) and "up" or "down" + local brightness_delta = math.abs(step_percent) + local resp, err = hue_api:set_light_level_delta(light_id, brightness_delta, action) + log_command_response_errors(resp, err, "step level action") +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +local function do_step_color_temp_action(driver, device, args) + local step_percent = args.args and args.args.stepSize or 0 + if step_percent == 0 then return end + local light_id, hue_api = get_light_device_id_and_hue_api_module(driver, device) + if not (light_id and hue_api) then return end + + -- Reminder, stepSize > 0 == Kelvin UP == Mireds DOWN. stepSize < 0 == Kelvin DOWN == Mireds UP + local action = (step_percent > 0) and "down" or "up" + + -- Derive the mirek range from stored Kelvin bounds (note: higher Kelvin = lower mirek) + local min_kelvin = device:get_field(Fields.MIN_KELVIN) or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE + local max_kelvin = device:get_field(Fields.MAX_KELVIN) or Consts.MAX_TEMP_KELVIN + local min_mirek = math.floor(utils.kelvin_to_mirek(max_kelvin)) + local max_mirek = math.ceil(utils.kelvin_to_mirek(min_kelvin)) + local mirek_delta = st_utils.round((max_mirek - min_mirek) * (math.abs(step_percent) / 100.0)) + + local resp, err = hue_api:set_light_color_temp_delta(light_id, mirek_delta, action) + log_command_response_errors(resp, err, "step color temp action") +end + ---@param driver HueDriver ---@param device HueChildDevice ---@param args table @@ -301,6 +251,20 @@ function CommandHandlers.set_color_temp_handler(driver, device, args) do_color_temp_action(driver, device, args) end +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.step_level_handler(driver, device, args) + do_step_level_action(driver, device, args) +end + +---@param driver HueDriver +---@param device HueChildDevice +---@param args table +function CommandHandlers.step_color_temp_handler(driver, device, args) + do_step_color_temp_action(driver, device, args) +end + local refresh_handlers = require "handlers.refresh_handlers" ---@param driver HueDriver diff --git a/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua index 1a1099fc5e..266971b99c 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua @@ -4,6 +4,7 @@ local Consts = require "consts" local Fields = require "fields" local HueColorUtils = require "utils.cie_utils" local grouped_utils = require "utils.grouped_utils" +local attribute_emitters = require "handlers.attribute_emitters" local utils = require "utils" @@ -94,7 +95,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common local function do_color_action(driver, bridge_device, group, args, aux) local hue, sat = (args.args.color.hue / 100), (args.args.color.saturation / 100) if hue == 1 then -- 0 and 360 degrees are equivalent in HSV, but not in our conversion function @@ -135,7 +136,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common local function do_setHue_action(driver, bridge_device, group, args, aux) local currentSaturation = aux[Fields.COLOR_SATURATION] or 0 args.args.color = { @@ -149,7 +150,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common local function do_setSaturation_action(driver, bridge_device, group, args, aux) local currentHue = aux[Fields.COLOR_HUE] or 0 args.args.color = { @@ -163,7 +164,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common local function do_color_temp_action(driver, bridge_device, group, args, aux) local kelvin = args.args.temperature @@ -185,6 +186,10 @@ local function do_color_temp_action(driver, bridge_device, group, args, aux) local clamped_kelvin = st_utils.clamp_value(kelvin, min, Consts.MAX_TEMP_KELVIN) local mirek = math.floor(utils.kelvin_to_mirek(clamped_kelvin)) + for _, device in ipairs(group.devices) do + attribute_emitters.emit_color_temp_when_mirek_unchanged(device, clamped_kelvin, mirek) + end + local resp, err = hue_api:set_grouped_light_color_temp(grouped_light_id, mirek) if not resp or (resp.errors and #resp.errors == 0) then @@ -203,7 +208,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common function GroupedLightCommandHandlers.switch_on_handler(driver, bridge_device, group, args, aux) do_switch_action(driver, bridge_device, group, args) end @@ -212,7 +217,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common function GroupedLightCommandHandlers.switch_off_handler(driver, bridge_device, group, args, aux) do_switch_action(driver, bridge_device, group, args) end @@ -221,7 +226,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common function GroupedLightCommandHandlers.switch_level_handler(driver, bridge_device, group, args, aux) do_switch_level_action(driver, bridge_device, group, args) end @@ -230,7 +235,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common function GroupedLightCommandHandlers.set_color_handler(driver, bridge_device, group, args, aux) do_color_action(driver, bridge_device, group, args, aux) end @@ -239,7 +244,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common function GroupedLightCommandHandlers.set_hue_handler(driver, bridge_device, group, args, aux) do_setHue_action(driver, bridge_device, group, args, aux) end @@ -248,7 +253,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common function GroupedLightCommandHandlers.set_saturation_handler(driver, bridge_device, group, args, aux) do_setSaturation_action(driver, bridge_device, group, args, aux) end @@ -257,7 +262,7 @@ end ---@param bridge_device HueBridgeDevice ---@param group table ---@param args table ----@param aux table auxilary data needed for the command that the devices all had in common +---@param aux table auxiliary data needed for the command that the devices all had in common function GroupedLightCommandHandlers.set_color_temp_handler(driver, bridge_device, group, args, aux) do_color_temp_action(driver, bridge_device, group, args, aux) end diff --git a/drivers/SmartThings/philips-hue/src/hue/api.lua b/drivers/SmartThings/philips-hue/src/hue/api.lua index e05fa3d0a0..6039b395cc 100644 --- a/drivers/SmartThings/philips-hue/src/hue/api.lua +++ b/drivers/SmartThings/philips-hue/src/hue/api.lua @@ -507,4 +507,52 @@ function PhilipsHueApi:set_light_color_temp_by_device_type(id, mirek, device_typ end end +---@param id string +---@param brightness_delta number absolute brightness percentage delta +---@param action "up"|"down" +---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error +---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself. +function PhilipsHueApi:set_light_level_delta(id, brightness_delta, action) + return self:set_light_level_delta_by_device_type(id, brightness_delta, action, HueDeviceTypes.LIGHT) +end + +function PhilipsHueApi:set_grouped_light_level_delta(id, brightness_delta, action) + return self:set_light_level_delta_by_device_type(id, brightness_delta, action, GROUPED_LIGHT) +end + +function PhilipsHueApi:set_light_level_delta_by_device_type(id, brightness_delta, action, device_type) + if type(brightness_delta) == "number" then + local url = string.format("/clip/v2/resource/%s/%s", device_type, id) + local payload = json.encode { dimming_delta = { action = action, brightness_delta = brightness_delta } } + return do_put(self, url, payload) + else + return nil, + string.format("Expected number for brightness delta, received %s", st_utils.stringify_table(brightness_delta, nil, false)) + end +end + +---@param id string +---@param mirek_delta number absolute mirek delta +---@param action "up"|"down" +---@return { errors: table[], [string]: any }? response json payload in response to the request, nil on error +---@return string? err error, nil on successful HTTP request but the response may indicate a problem with the request itself. +function PhilipsHueApi:set_light_color_temp_delta(id, mirek_delta, action) + return self:set_light_color_temp_delta_by_device_type(id, mirek_delta, action, HueDeviceTypes.LIGHT) +end + +function PhilipsHueApi:set_grouped_light_color_temp_delta(id, mirek_delta, action) + return self:set_light_color_temp_delta_by_device_type(id, mirek_delta, action, GROUPED_LIGHT) +end + +function PhilipsHueApi:set_light_color_temp_delta_by_device_type(id, mirek_delta, action, device_type) + if type(mirek_delta) == "number" then + local url = string.format("/clip/v2/resource/%s/%s", device_type, id) + local payload = json.encode { color_temperature_delta = { action = action, mirek_delta = mirek_delta } } + return do_put(self, url, payload) + else + return nil, + string.format("Expected number for color temp mirek delta, received %s", st_utils.stringify_table(mirek_delta, nil, false)) + end +end + return PhilipsHueApi diff --git a/drivers/SmartThings/philips-hue/src/hue_driver_template.lua b/drivers/SmartThings/philips-hue/src/hue_driver_template.lua index 3ffd02b3b6..1c32b260e9 100644 --- a/drivers/SmartThings/philips-hue/src/hue_driver_template.lua +++ b/drivers/SmartThings/philips-hue/src/hue_driver_template.lua @@ -59,6 +59,8 @@ local set_color_handler = utils.safe_wrap_handler(command_handlers.set_color_han local set_hue_handler = utils.safe_wrap_handler(command_handlers.set_hue_handler) local set_saturation_handler = utils.safe_wrap_handler(command_handlers.set_saturation_handler) local set_color_temp_handler = utils.safe_wrap_handler(command_handlers.set_color_temp_handler) +local step_level_handler = utils.safe_wrap_handler(command_handlers.step_level_handler) +local step_color_temp_handler = utils.safe_wrap_handler(command_handlers.step_color_temp_handler) --- @class HueDriverDatastore --- @field public bridge_netinfo table @@ -105,6 +107,12 @@ function HueDriver.new_driver_template(dbg_config) [capabilities.colorTemperature.ID] = { [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = step_level_handler, + }, + [capabilities.statelessColorTemperatureStep.ID] = { + [capabilities.statelessColorTemperatureStep.commands.stepColorTemperatureByPercent.NAME] = step_color_temp_handler, + }, }, -- override the default capability message handler if batched receives are supported diff --git a/drivers/SmartThings/samsung-audio/src/command.lua b/drivers/SmartThings/samsung-audio/src/command.lua index deca7c4416..d03fe07850 100644 --- a/drivers/SmartThings/samsung-audio/src/command.lua +++ b/drivers/SmartThings/samsung-audio/src/command.lua @@ -34,6 +34,19 @@ local function is_empty(t) return not t or (type(t) == "table" and #t == 0) end +local function get_uic_response(ret, command_name) + local root = ret and ret.handler_res and ret.handler_res.root + local uic = root and root.UIC + local response = uic and uic.response + + if not uic then + log.warn(string.format("Missing UIC data in %s response", tostring(command_name))) + return nil, nil + end + + return uic, response +end + local function tr(s,mappings) return string.gsub(s, "(.)", @@ -94,8 +107,9 @@ function Command.volume(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetVolume") local ret = handle_http_request(ip, url) - if ret then - response_map = { volume = ret.handler_res.root.UIC.response.volume, } + local _, response = get_uic_response(ret, "GetVolume") + if response and response.volume ~= nil then + response_map = { volume = response.volume, } end end return response_map @@ -114,8 +128,9 @@ function Command.set_volume(ip, level) local encoded_str_vol = "/UIC?cmd=%3Cpwron%3Eon%3C/pwron%3E%3Cname%3ESetVolume%3C/name%3E%3Cp%20type=%22dec%22%20name=%22volume%22%20val=%22" .. level .. "%22%3E%3C/p%3E" local url = format_url(ip, encoded_str_vol) local ret = handle_http_request(ip, url) - if ret then - response_map = { volume = ret.handler_res.root.UIC.response.volume, } + local _, response = get_uic_response(ret, "SetVolume") + if response and response.volume ~= nil then + response_map = { volume = response.volume, } end end return response_map @@ -326,8 +341,9 @@ function Command.getMute(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetMute") local ret = handle_http_request(ip, url) - if ret then - response_map = { muted = ret.handler_res.root.UIC.response.mute,} + local _, response = get_uic_response(ret, "GetMute") + if response and response.mute ~= nil then + response_map = { muted = response.mute,} end end return response_map @@ -342,8 +358,9 @@ function Command.getPlayStatus(ip) if ip then local url = format_url(ip, "/UIC?cmd=GetPlayStatus") local ret = handle_http_request(ip, url) - if ret then - response_map = { playstatus = ret.handler_res.root.UIC.response.playstatus,} + local _, response = get_uic_response(ret, "GetPlayStatus") + if response and response.playstatus ~= nil then + response_map = { playstatus = response.playstatus,} end end return response_map diff --git a/drivers/SmartThings/samsung-audio/src/handlers.lua b/drivers/SmartThings/samsung-audio/src/handlers.lua index 6233a86326..65a0027bf9 100644 --- a/drivers/SmartThings/samsung-audio/src/handlers.lua +++ b/drivers/SmartThings/samsung-audio/src/handlers.lua @@ -137,7 +137,7 @@ end function CapabilityHandlers.handle_audio_notification(driver, device, cmd) local ip = device:get_field("ip") local mute_status = command.getMute(ip) - if mute_status.muted ~= "off" then + if mute_status and mute_status.muted ~= nil and mute_status.muted ~= "off" then --unmute before playig notification command.unmute(ip) end diff --git a/drivers/SmartThings/samsung-audio/src/init.lua b/drivers/SmartThings/samsung-audio/src/init.lua index de1958ff25..2e51b8e0b4 100644 --- a/drivers/SmartThings/samsung-audio/src/init.lua +++ b/drivers/SmartThings/samsung-audio/src/init.lua @@ -94,14 +94,23 @@ local function emit_refresh_data_to_server(driver, device, cmd) -- get volume local vol = command.volume(device:get_field("ip")) - device:emit_event(capabilities.audioVolume.volume(tonumber(vol.volume))) + local current_volume = vol and tonumber(vol.volume) + if current_volume ~= nil then + device:emit_event(capabilities.audioVolume.volume(current_volume)) + else + log.warn("Unable to read speaker volume from refresh response") + end -- get mute status local muteStatus = command.getMute(device:get_field("ip")) - if muteStatus.muted ~= "off" then - device:emit_event(capabilities.audioMute.mute.muted()) + if muteStatus and muteStatus.muted ~= nil then + if muteStatus.muted ~= "off" then + device:emit_event(capabilities.audioMute.mute.muted()) + else + device:emit_event(capabilities.audioMute.mute.unmuted()) + end else - device:emit_event(capabilities.audioMute.mute.unmuted()) + log.warn("Unable to read speaker mute state from refresh response") end end diff --git a/drivers/SmartThings/sonos/src/api/sonos_connection.lua b/drivers/SmartThings/sonos/src/api/sonos_connection.lua index 471c3a04fb..4852fc517c 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_connection.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_connection.lua @@ -487,9 +487,11 @@ function SonosConnection.new(driver, device) return end - local url_ip = lb_utils.force_url_table(coordinator_player.player.websocket_url).host + local url_table = lb_utils.force_url_table(coordinator_player.player.websocket_url) + local url_ip = url_table.host + local url_port = url_table.port or SonosApi.DEFAULT_SONOS_PORT local base_url = lb_utils.force_url_table( - string.format("https://%s:%s", url_ip, SonosApi.DEFAULT_SONOS_PORT) + string.format("https://%s:%s", url_ip, url_port) ) local _, api_key = driver:check_auth(device) local maybe_token = driver:get_oauth_token() @@ -545,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 @@ -564,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 @@ -584,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 @@ -683,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 @@ -698,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_ssdp_discovery.lua b/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua index ddc1d0842d..21cbdcd62c 100644 --- a/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua +++ b/drivers/SmartThings/sonos/src/api/sonos_ssdp_discovery.lua @@ -421,12 +421,11 @@ function sonos_ssdp.spawn_persistent_ssdp_task() if is_new_information then local headers = SonosApi.make_headers() - local discovery_info, err = SonosApi.RestApi.get_player_info( - net_url.parse( - string.format("https://%s:%s", sonos_ssdp_info.ip, SonosApi.DEFAULT_SONOS_PORT) - ), - headers + local parsed_wss_url = net_url.parse(sonos_ssdp_info.wss_url) or {} + local base_url = net_url.parse( + string.format("https://%s:%s", parsed_wss_url.host, parsed_wss_url.port or SonosApi.DEFAULT_SONOS_PORT) ) + local discovery_info, err = SonosApi.RestApi.get_player_info(base_url, headers) if not discovery_info then log.error(string.format("Error getting discovery info from SSDP response: %s", err)) elseif discovery_info._objectType == "globalError" then 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] diff --git a/drivers/SmartThings/sonos/src/sonos_driver.lua b/drivers/SmartThings/sonos/src/sonos_driver.lua index 1f63ef21d3..56ccb2335f 100644 --- a/drivers/SmartThings/sonos/src/sonos_driver.lua +++ b/drivers/SmartThings/sonos/src/sonos_driver.lua @@ -1,4 +1,6 @@ -local api_version = require "version".api +local version = require "version" +local api_version = version.api +local rpc_version = version.rpc local capabilities = require "st.capabilities" local cosock = require "cosock" local json = require "st.json" @@ -651,6 +653,59 @@ local function do_refresh(driver, device, cmd) sonos_conn:refresh_subscriptions() end +--- In RPC version 100, the events for augmented driver store were changed +--- to no longer match the parsing done in API version 18 and below. +--- +--- API version 19 will handle both before and after RPC 100 changes so this only needs +--- to be applied for RPC version >= 100 and API version <= 18. +if rpc_version >= 100 and api_version <= 18 then + log.info_with({ hub_logs = true }, "Overriding environment info handler for RPC >= 100 and API <= 18") + function SonosDriver:environment_info_handler(channel) + log.info_with({ hub_logs = true }, "Starting environment info handler for RPC >= 100 and API <= 18") + local msg_type, msg_val = channel:receive() + -- This driver only cares about augmentDriverStore messages currently. + -- Previously, this was augmentDatastore msg_type. + if msg_type == "augmentDriverStore" then + if type(msg_val.payload) ~= "table" then + log.warn( + string.format( + "Unexpected augmentDriverStore payload type: %s", + type(msg_val.payload) + ) + ) + return + end + -- The field evt_kind was renamed to kind and is a string of the enum rather than + -- the integer value of the enum. + if msg_val.kind == "Upsert" then + -- This type was changed to table of u8s instead of a string + local data = "" + for _, v in pairs(msg_val.payload.data_value) do + data = data .. string.char(v) + end + self.hub_augmented_driver_data[msg_val.payload.data_key] = data + -- notify with the updated record + if self.notify_augmented_data_changed ~= nil then + self:notify_augmented_data_changed("upsert", msg_val.payload.data_key, data) + end + elseif msg_val.kind == "Delete" then + self.hub_augmented_driver_data[msg_val.payload.data_key] = nil + -- notify with just the key that got deleted + if self.notify_augmented_data_changed ~= nil then + self:notify_augmented_data_changed("delete", msg_val.payload.data_key) + end + else + log.warn( + string.format( + "Unexpected augmentDriverStore kind: %s", + msg_val.kind + ) + ) + end + end + end +end + function SonosDriver.new_driver_template() local oauth_token_bus = cosock.bus() local oauth_info_bus = cosock.bus() @@ -667,6 +722,8 @@ function SonosDriver.new_driver_template() bonded_devices = utils.new_mac_address_keyed_table(), dni_to_device_id = utils.new_mac_address_keyed_table(), lifecycle_handlers = SonosDriverLifecycleHandlers, + -- Only overriden in the case of RPC version 100+ with API version <= 18 + environment_info_handler = SonosDriver.environment_info_handler, capability_handlers = { [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, diff --git a/drivers/SmartThings/virtual-switch/src/test/test_virtual_switch.lua b/drivers/SmartThings/virtual-switch/src/test/test_virtual_switch.lua index 651668297b..1feb54fb0d 100644 --- a/drivers/SmartThings/virtual-switch/src/test/test_virtual_switch.lua +++ b/drivers/SmartThings/virtual-switch/src/test/test_virtual_switch.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" local capabilities = require "st.capabilities" @@ -41,6 +44,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -57,6 +63,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.on({state_change=true})) } + }, + { + min_api_version = 14 } ) @@ -73,6 +82,9 @@ test.register_message_test( direction = "send", message = mock_device_no_prefs:generate_test_message("main", capabilities.switch.switch.on({state_change=true})) } + }, + { + min_api_version = 14 } ) @@ -90,6 +102,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.off({state_change=true})) } + }, + { + min_api_version = 14 } ) @@ -106,6 +121,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.off({state_change=true})) } + }, + { + min_api_version = 14 } ) @@ -128,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -146,7 +167,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_simple_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-air-quality-detector/src/init.lua b/drivers/SmartThings/zigbee-air-quality-detector/src/init.lua index 993ba2a96d..49e2f79a88 100755 --- a/drivers/SmartThings/zigbee-air-quality-detector/src/init.lua +++ b/drivers/SmartThings/zigbee-air-quality-detector/src/init.lua @@ -33,7 +33,8 @@ local zigbee_air_quality_detector_template = { capabilities.tvocMeasurement, capabilities.tvocHealthConcern }, - sub_drivers = { require("MultiIR") } + sub_drivers = { require("MultiIR") }, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_air_quality_detector_template, zigbee_air_quality_detector_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-air-quality-detector/src/test/test_MultiIR_air_quality_detector.lua b/drivers/SmartThings/zigbee-air-quality-detector/src/test/test_MultiIR_air_quality_detector.lua old mode 100755 new mode 100644 index 883a3dc5a5..b9ae4cab1a --- a/drivers/SmartThings/zigbee-air-quality-detector/src/test/test_MultiIR_air_quality_detector.lua +++ b/drivers/SmartThings/zigbee-air-quality-detector/src/test/test_MultiIR_air_quality_detector.lua @@ -71,7 +71,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, read_tvoc_messge}) test.socket.zigbee:__expect_send({mock_device.id, read_carbonDioxide_messge}) test.socket.zigbee:__expect_send({mock_device.id, read_AQI_messge}) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -90,6 +93,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 40 })) } + }, + { + min_api_version = 14 } ) @@ -109,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) } + }, + { + min_api_version = 14 } ) @@ -126,7 +135,10 @@ test.register_coroutine_test( capabilities.carbonDioxideMeasurement.carbonDioxide({value = 1400, unit = "ppm"}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern({value = "good"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -143,7 +155,10 @@ test.register_coroutine_test( capabilities.fineDustSensor.fineDustLevel({value = 74 }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fineDustHealthConcern.fineDustHealthConcern.good())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -160,7 +175,10 @@ test.register_coroutine_test( capabilities.veryFineDustSensor.veryFineDustLevel({value = 69 }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.veryFineDustHealthConcern.veryFineDustHealthConcern.good())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -177,7 +195,10 @@ test.register_coroutine_test( capabilities.dustSensor.dustLevel({value = 69 }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.dustHealthConcern.dustHealthConcern.good())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -192,7 +213,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.formaldehydeMeasurement.formaldehydeLevel({value = 1000.0, unit = "mg/m^3"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -209,7 +233,10 @@ test.register_coroutine_test( capabilities.tvocMeasurement.tvocLevel({value = 1000.0, unit = "ug/m3"}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tvocHealthConcern.tvocHealthConcern({value = "unhealthy"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -224,7 +251,240 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "good"}))) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "AQI moderate (51-100) emits moderate airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 75 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "moderate"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "AQI slightlyUnhealthy (101-150) emits slightlyUnhealthy airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 125 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "slightlyUnhealthy"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "AQI unhealthy (151-200) emits unhealthy airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 175 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "AQI veryUnhealthy (201-300) emits veryUnhealthy airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 250 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "veryUnhealthy"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "AQI hazardous (>=301) emits hazardous airQualityHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 350 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC5, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualityHealthConcern.airQualityHealthConcern({value = "hazardous"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "carbonDioxide moderate (1501-2500) emits moderate carbonDioxideHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 2000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC3, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideMeasurement.carbonDioxide({value = 2000, unit = "ppm"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern({value = "moderate"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "carbonDioxide unhealthy (>2500) emits unhealthy carbonDioxideHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 3000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC3, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideMeasurement.carbonDioxide({value = 3000, unit = "ppm"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.carbonDioxideHealthConcern.carbonDioxideHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "pm2.5 moderate emits moderate fineDustHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 90 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustSensor.fineDustLevel({value = 90}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustHealthConcern.fineDustHealthConcern({value = "moderate"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "pm2.5 unhealthy (>=115) emits unhealthy fineDustHealthConcern", + function() + local attr_report_data = { + { 0x0000, data_types.Uint16.ID, 120 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustSensor.fineDustLevel({value = 120}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fineDustHealthConcern.fineDustHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "pm1.0 unhealthy (>100) emits unhealthy veryFineDustHealthConcern", + function() + local attr_report_data = { + { 0x0001, data_types.Uint16.ID, 150 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.veryFineDustSensor.veryFineDustLevel({value = 150}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.veryFineDustHealthConcern.veryFineDustHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "pm10 unhealthy (>150) emits unhealthy dustHealthConcern", + function() + local attr_report_data = { + { 0x0002, data_types.Uint16.ID, 200 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC1, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.dustSensor.dustLevel({value = 200}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.dustHealthConcern.dustHealthConcern({value = "unhealthy"}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "tvoc good (<600) emits good tvocHealthConcern", + function() + local attr_report_data = { + { 0x0001, data_types.SinglePrecisionFloat.ID, SinglePrecisionFloat(0, 8, 0.953125) } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, 0xFCC2, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocMeasurement.tvocLevel({value = 500.0, unit = "ug/m3"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocHealthConcern.tvocHealthConcern({value = "good"}))) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-bed/src/init.lua b/drivers/SmartThings/zigbee-bed/src/init.lua index d59cf4e012..9d3e798b8b 100755 --- a/drivers/SmartThings/zigbee-bed/src/init.lua +++ b/drivers/SmartThings/zigbee-bed/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -20,10 +10,9 @@ local zigbee_bed_template = { supported_capabilities = { capabilities.refresh, }, - sub_drivers = { - require("shus-mattress"), - }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_bed_template, zigbee_bed_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-bed/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-bed/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/can_handle.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/can_handle.lua new file mode 100644 index 0000000000..0adcc5e46e --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_shus_products(opts, driver, device) + local FINGERPRINTS = require("shus-mattress.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("shus-mattress") + end + end + return false +end + +return is_shus_products diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_capabilities.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_capabilities.lua index 2c3dab5292..fdd5ab0004 100755 --- a/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_capabilities.lua +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_capabilities.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_clusters.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_clusters.lua index 35baddbaa9..db220da460 100755 --- a/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_clusters.lua +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/custom_clusters.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local data_types = require "st.zigbee.data_types" diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/fingerprints.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/fingerprints.lua new file mode 100644 index 0000000000..2d6c80ee1a --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "SHUS", model = "SX-1" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-bed/src/shus-mattress/init.lua b/drivers/SmartThings/zigbee-bed/src/shus-mattress/init.lua index 08109f35ce..65cf64e446 100755 --- a/drivers/SmartThings/zigbee-bed/src/shus-mattress/init.lua +++ b/drivers/SmartThings/zigbee-bed/src/shus-mattress/init.lua @@ -1,25 +1,12 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cluster_base = require "st.zigbee.cluster_base" local custom_clusters = require "shus-mattress/custom_clusters" local custom_capabilities = require "shus-mattress/custom_capabilities" -local FINGERPRINTS = { - { mfr = "SHUS", model = "SX-1" } -} -- ############################# -- # Attribute handlers define # @@ -155,14 +142,6 @@ end local function do_configure(driver, device) end -local function is_shus_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end -- ################# -- # Handlers bind # @@ -229,7 +208,7 @@ local shus_smart_mattress = { ["stateControl"] = process_capabilities_factory("stateControl","yoga") } }, - can_handle = is_shus_products + can_handle = require("shus-mattress.can_handle"), } return shus_smart_mattress diff --git a/drivers/SmartThings/zigbee-bed/src/sub_drivers.lua b/drivers/SmartThings/zigbee-bed/src/sub_drivers.lua new file mode 100644 index 0000000000..3e78841f3b --- /dev/null +++ b/drivers/SmartThings/zigbee-bed/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("shus-mattress"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua b/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua old mode 100755 new mode 100644 index d3922a3bae..08eb79e2a9 --- a/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua +++ b/drivers/SmartThings/zigbee-bed/src/test/test_shus_mattress.lua @@ -1,924 +1,1119 @@ --- Copyright 2024 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. - --- Mock out globals -local test = require "integration_test" -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") -test.add_package_capability("autoInflation.yaml") -test.add_package_capability("leftControl.yaml") -test.add_package_capability("rightControl.yaml") -test.add_package_capability("strongExpMode.yaml") -test.add_package_capability("yoga.yaml") -test.add_package_capability("mattressHardness.yaml") - -local PRIVATE_CLUSTER_ID = 0xFCC2 -local MFG_CODE = 0x1235 - -local mock_device = test.mock_device.build_test_zigbee_device( -{ - label = "Shus Smart Mattress", - profile = shus_mattress_profile_def, - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "SHUS", - model = "SX-1", - server_clusters = { 0x0000,PRIVATE_CLUSTER_ID } - } - } -}) - -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - test.mock_device.add_test_device(mock_device) - zigbee_test_utils.init_noop_health_check_timer() -end - -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "lifecycle - added test", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.yoga.supportedYogaState({"stop", "left", "right"}, { visibility = { displayed = false }}) )) - local read_0x0006_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0006, MFG_CODE) - local read_0x0007_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0007, MFG_CODE) - local read_0x0009_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0009, MFG_CODE) - local read_0x000a_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000a, MFG_CODE) - local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) - local read_0x0001_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE) - local read_0x0002_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE) - local read_0x0003_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0003, MFG_CODE) - local read_0x0004_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0004, MFG_CODE) - local read_0x0005_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0005, MFG_CODE) - local read_0x0008_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0008, MFG_CODE) - local read_0x000C_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000C, MFG_CODE) - local read_0x000D_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000D, MFG_CODE) - local read_0x000E_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000E, MFG_CODE) - local read_0x000F_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000F, MFG_CODE) - local read_0x0010_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0010, MFG_CODE) - local read_0x0011_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0011, MFG_CODE) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0006_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0007_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0009_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000a_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0002_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0003_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0004_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0005_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0008_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000C_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000D_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000E_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000F_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0010_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0011_messge}) - end -) - -test.register_coroutine_test( - "capability - refresh", - function() - test.socket.capability:__queue_receive({ mock_device.id, - { capability = "refresh", component = "main", command = "refresh", args = {} } }) - local read_0x0006_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0006, MFG_CODE) - local read_0x0007_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0007, MFG_CODE) - local read_0x0009_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0009, MFG_CODE) - local read_0x000a_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000a, MFG_CODE) - local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) - local read_0x0001_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE) - local read_0x0002_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE) - local read_0x0003_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0003, MFG_CODE) - local read_0x0004_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0004, MFG_CODE) - local read_0x0005_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0005, MFG_CODE) - local read_0x0008_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0008, MFG_CODE) - local read_0x000C_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000C, MFG_CODE) - local read_0x000D_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000D, MFG_CODE) - local read_0x000E_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000E, MFG_CODE) - local read_0x000F_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000F, MFG_CODE) - local read_0x0010_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0010, MFG_CODE) - local read_0x0011_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0011, MFG_CODE) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0006_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0007_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0009_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000a_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0002_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0003_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0004_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0005_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0008_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000C_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000D_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000E_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x000F_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0010_messge}) - test.socket.zigbee:__expect_send({mock_device.id, read_0x0011_messge}) - end -) - -test.register_coroutine_test( - "Device reported leftback 0 and driver emit custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0000, data_types.Uint8.ID, 0 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported leftback 1 and driver emit custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0000, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported leftwaist 0 and driver emit custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0001, data_types.Uint8.ID, 0 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported leftwaist 1 and driver emit custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0001, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported lefthip 0 and driver emit custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0002, data_types.Uint8.ID, 0 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported lefthip 1 and driver emit custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0002, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported rightback 0 and driver emit custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0003, data_types.Uint8.ID, 0 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported rightback 1 and driver emit custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0003, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported rightwaist 0 and driver emit custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0004, data_types.Uint8.ID, 0 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported rightwaist 1 and driver emit custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0004, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported righthip 0 and driver emit custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0005, data_types.Uint8.ID, 0 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported righthip 1 and driver emit custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }})", - function() - local attr_report_data = { - { 0x0005, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }}))) - end -) - -test.register_coroutine_test( - "Device reported leftBackHardness 1 and driver emit custom_capabilities.mattressHardness.leftBackHardness(1)", - function() - local attr_report_data = { - { 0x000C, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.mattressHardness.leftBackHardness(1))) - end -) - -test.register_coroutine_test( - "Device reported leftWaistHardness 1 and driver emit custom_capabilities.mattressHardness.leftWaistHardness(1)", - function() - local attr_report_data = { - { 0x000D, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.mattressHardness.leftWaistHardness(1))) - end -) - -test.register_coroutine_test( - "Device reported leftHipHardness 1 and driver emit custom_capabilities.mattressHardness.leftHipHardness(1)", - function() - local attr_report_data = { - { 0x000E, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.mattressHardness.leftHipHardness(1))) - end -) - -test.register_coroutine_test( - "Device reported rightBackHardness 1 and driver emit custom_capabilities.mattressHardness.rightBackHardness(1)", - function() - local attr_report_data = { - { 0x000F, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.mattressHardness.rightBackHardness(1))) - end -) - -test.register_coroutine_test( - "Device reported rightWaistHardness 1 and driver emit custom_capabilities.mattressHardness.rightWaistHardness(1)", - function() - local attr_report_data = { - { 0x0010, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.mattressHardness.rightWaistHardness(1))) - end -) - -test.register_coroutine_test( - "Device reported rightHipHardness 1 and driver emit custom_capabilities.mattressHardness.rightHipHardness(1)", - function() - local attr_report_data = { - { 0x0011, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.mattressHardness.rightHipHardness(1))) - end -) - -test.register_coroutine_test( - "Device reported yoga 2 and driver emit custom_capabilities.yoga.state.right()", - function() - local attr_report_data = { - { 0x0008, data_types.Uint8.ID, 2 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.yoga.state.right())) - end -) - -test.register_coroutine_test( - "Device reported yoga 1 and driver emit custom_capabilities.yoga.state.left()", - function() - local attr_report_data = { - { 0x0008, data_types.Uint8.ID, 1 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.yoga.state.left())) - end -) - -test.register_coroutine_test( - "Device reported yoga 0 and driver emit custom_capabilities.yoga.state.stop()", - function() - local attr_report_data = { - { 0x0008, data_types.Uint8.ID, 0 } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.yoga.state.stop())) - end -) - -test.register_coroutine_test( - "Device reported ai_mode left false and driver emit custom_capabilities.ai_mode.left.off()", - function() - local attr_report_data = { - { 0x0006, data_types.Boolean.ID, false } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.ai_mode.left.off())) - end -) - -test.register_coroutine_test( - "Device reported ai_mode left true and driver emit custom_capabilities.ai_mode.left.on()", - function() - local attr_report_data = { - { 0x0006, data_types.Boolean.ID, true } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.ai_mode.left.on())) - end -) - -test.register_coroutine_test( - "Device reported ai_mode right true and driver emit custom_capabilities.ai_mode.right.on()", - function() - local attr_report_data = { - { 0x0007, data_types.Boolean.ID, true } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.ai_mode.right.on())) - end -) - -test.register_coroutine_test( - "Device reported ai_mode right false and driver emit custom_capabilities.ai_mode.right.off()", - function() - local attr_report_data = { - { 0x0007, data_types.Boolean.ID, false } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.ai_mode.right.off())) - end -) - -test.register_coroutine_test( - "Device reported inflationState false and driver emit custom_capabilities.auto_inflation.inflationState.off()", - function() - local attr_report_data = { - { 0x0009, data_types.Boolean.ID, false } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.auto_inflation.inflationState.off())) - end -) - -test.register_coroutine_test( - "Device reported inflationState true and driver emit custom_capabilities.auto_inflation.inflationState.on()", - function() - local attr_report_data = { - { 0x0009, data_types.Boolean.ID, true } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.auto_inflation.inflationState.on())) - end -) - -test.register_coroutine_test( - "Device reported strong_exp_mode false and driver emit custom_capabilities.strong_exp_mode.expState.off()", - function() - local attr_report_data = { - { 0x000a, data_types.Boolean.ID, false } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.strong_exp_mode.expState.off())) - end -) - -test.register_coroutine_test( - "Device reported strong_exp_mode true and driver emit custom_capabilities.strong_exp_mode.expState.on()", - function() - local attr_report_data = { - { 0x000a, data_types.Boolean.ID, true } - } - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.strong_exp_mode.expState.on())) - end -) - - -test.register_coroutine_test( - "capability leftControl on and driver send on ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.ai_mode.ID, component = "main", command ="leftControl" , args = {"on"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0006, MFG_CODE, data_types.Boolean, true) - }) - end -) - -test.register_coroutine_test( - "capability leftControl off and driver send off ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.ai_mode.ID, component = "main", command ="leftControl" , args = {"off"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0006, MFG_CODE, data_types.Boolean, false) - }) - end -) - -test.register_coroutine_test( - "capability rightControl on and driver send on ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.ai_mode.ID, component = "main", command ="rightControl" , args = {"on"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0007, MFG_CODE, data_types.Boolean, true) - }) - end -) - -test.register_coroutine_test( - "capability rightControl off and driver send off ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.ai_mode.ID, component = "main", command ="rightControl" , args = {"off"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0007, MFG_CODE, data_types.Boolean, false) - }) - end -) - -test.register_coroutine_test( - "capability auto_inflation stateControl on and driver send on ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.auto_inflation.ID, component = "main", command ="stateControl" , args = {"on"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0009, MFG_CODE, data_types.Boolean, true) - }) - end -) - -test.register_coroutine_test( - "capability auto_inflation stateControl off and driver send off ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.auto_inflation.ID, component = "main", command ="stateControl" , args = {"off"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0009, MFG_CODE, data_types.Boolean, false) - }) - end -) - -test.register_coroutine_test( - "capability strong_exp_mode stateControl on and driver send on ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.strong_exp_mode.ID, component = "main", command ="stateControl" , args = {"on"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x000a, MFG_CODE, data_types.Boolean, true) - }) - end -) - -test.register_coroutine_test( - "capability strong_exp_mode stateControl off and driver send off ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.strong_exp_mode.ID, component = "main", command ="stateControl" , args = {"off"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x000a, MFG_CODE, data_types.Boolean, false) - }) - end -) - -test.register_coroutine_test( - "capability left_control backControl soft and driver send soft ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.left_control.ID, component = "main", command ="backControl" , args = {"soft"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0000, MFG_CODE, data_types.Uint8, 0) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.leftback.soft())) - end -) - -test.register_coroutine_test( - "capability waistControl backControl soft and driver send soft ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.left_control.ID, component = "main", command ="waistControl" , args = {"soft"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0001, MFG_CODE, data_types.Uint8, 0) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.leftwaist.soft())) - end -) - -test.register_coroutine_test( - "capability left_control hipControl soft and driver send soft ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.left_control.ID, component = "main", command ="hipControl" , args = {"soft"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0002, MFG_CODE, data_types.Uint8, 0) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.lefthip.soft())) - end -) - -test.register_coroutine_test( - "capability left_control backControl hard and driver send hard ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.left_control.ID, component = "main", command ="backControl" , args = {"hard"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0000, MFG_CODE, data_types.Uint8, 1) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.leftback.hard())) - end -) - -test.register_coroutine_test( - "capability waistControl backControl hard and driver send hard ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.left_control.ID, component = "main", command ="waistControl" , args = {"hard"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0001, MFG_CODE, data_types.Uint8, 1) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.leftwaist.hard())) - end -) - -test.register_coroutine_test( - "capability left_control hipControl hard and driver send hard ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.left_control.ID, component = "main", command ="hipControl" , args = {"hard"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0002, MFG_CODE, data_types.Uint8, 1) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.left_control.lefthip.hard())) - end -) - -test.register_coroutine_test( - "capability right_control backControl soft and driver send soft ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.right_control.ID, component = "main", command ="backControl" , args = {"soft"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0003, MFG_CODE, data_types.Uint8, 0) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.rightback.soft())) - end -) - -test.register_coroutine_test( - "capability right_control waistControl soft and driver send soft ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.right_control.ID, component = "main", command ="waistControl" , args = {"soft"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0004, MFG_CODE, data_types.Uint8, 0) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.rightwaist.soft())) - end -) - -test.register_coroutine_test( - "capability right_control hipControl soft and driver send soft ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.right_control.ID, component = "main", command ="hipControl" , args = {"soft"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0005, MFG_CODE, data_types.Uint8, 0) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.righthip.soft())) - end -) - -test.register_coroutine_test( - "capability right_control backControl hard and driver send hard ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.right_control.ID, component = "main", command ="backControl" , args = {"hard"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0003, MFG_CODE, data_types.Uint8, 1) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.rightback.hard())) - end -) - -test.register_coroutine_test( - "capability right_control waistControl hard and driver send hard ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.right_control.ID, component = "main", command ="waistControl" , args = {"hard"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0004, MFG_CODE, data_types.Uint8, 1) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.rightwaist.hard())) - end -) - -test.register_coroutine_test( - "capability right_control hipControl hard and driver send hard ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.right_control.ID, component = "main", command ="hipControl" , args = {"hard"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0005, MFG_CODE, data_types.Uint8, 1) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", - custom_capabilities.right_control.righthip.hard())) - end -) - -test.register_coroutine_test( - "capability yoga stateControl left and driver send left ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"left"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0008, MFG_CODE, data_types.Uint8, 1) - }) - end -) - -test.register_coroutine_test( - "capability yoga stateControl right and driver send right ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"right"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0008, MFG_CODE, data_types.Uint8, 2) - }) - end -) - -test.register_coroutine_test( - "capability yoga stateControl stop and driver send stop ", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"stop"}} - }) - test.socket.zigbee:__expect_send({ mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, - 0x0008, MFG_CODE, data_types.Uint8, 0) - }) - end -) - -test.run_registered_tests() +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +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 shus_mattress_profile_def = t_utils.get_profile_definition("shus-smart-mattress.yml") +test.add_package_capability("aiMode.yaml") +test.add_package_capability("autoInflation.yaml") +test.add_package_capability("leftControl.yaml") +test.add_package_capability("rightControl.yaml") +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 + +local mock_device = test.mock_device.build_test_zigbee_device( +{ + label = "Shus Smart Mattress", + profile = shus_mattress_profile_def, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SHUS", + model = "SX-1", + server_clusters = { 0x0000,PRIVATE_CLUSTER_ID } + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.supportedYogaState({"stop", "left", "right"}, { visibility = { displayed = false }}) )) + local read_0x0006_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0006, MFG_CODE) + local read_0x0007_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0007, MFG_CODE) + local read_0x0009_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0009, MFG_CODE) + local read_0x000a_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000a, MFG_CODE) + local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) + local read_0x0001_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE) + local read_0x0002_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE) + local read_0x0003_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0003, MFG_CODE) + local read_0x0004_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0004, MFG_CODE) + local read_0x0005_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0005, MFG_CODE) + local read_0x0008_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0008, MFG_CODE) + local read_0x000C_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000C, MFG_CODE) + local read_0x000D_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000D, MFG_CODE) + local read_0x000E_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000E, MFG_CODE) + local read_0x000F_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000F, MFG_CODE) + local read_0x0010_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0010, MFG_CODE) + local read_0x0011_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0011, MFG_CODE) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0006_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0007_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0009_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000a_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0002_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0003_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0004_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0005_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0008_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000C_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000D_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000E_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000F_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0010_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0011_messge}) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability - refresh", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + local read_0x0006_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0006, MFG_CODE) + local read_0x0007_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0007, MFG_CODE) + local read_0x0009_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0009, MFG_CODE) + local read_0x000a_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000a, MFG_CODE) + local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) + local read_0x0001_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE) + local read_0x0002_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE) + local read_0x0003_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0003, MFG_CODE) + local read_0x0004_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0004, MFG_CODE) + local read_0x0005_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0005, MFG_CODE) + local read_0x0008_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0008, MFG_CODE) + local read_0x000C_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000C, MFG_CODE) + local read_0x000D_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000D, MFG_CODE) + local read_0x000E_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000E, MFG_CODE) + local read_0x000F_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x000F, MFG_CODE) + local read_0x0010_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0010, MFG_CODE) + local read_0x0011_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0011, MFG_CODE) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0006_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0007_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0009_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000a_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0002_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0003_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0004_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0005_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0008_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000C_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000D_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000E_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x000F_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0010_messge}) + test.socket.zigbee:__expect_send({mock_device.id, read_0x0011_messge}) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported leftback 0 and driver emit custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported leftback 1 and driver emit custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0000, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported leftwaist 0 and driver emit custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0001, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported leftwaist 1 and driver emit custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0001, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftwaist.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported lefthip 0 and driver emit custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0002, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported lefthip 1 and driver emit custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0002, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.lefthip.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported rightback 0 and driver emit custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0003, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported rightback 1 and driver emit custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0003, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightback.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported rightwaist 0 and driver emit custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0004, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported rightwaist 1 and driver emit custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0004, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightwaist.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported righthip 0 and driver emit custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0005, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported righthip 1 and driver emit custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }})", + function() + local attr_report_data = { + { 0x0005, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.righthip.idle({ visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported leftBackHardness 1 and driver emit custom_capabilities.mattressHardness.leftBackHardness(1)", + function() + local attr_report_data = { + { 0x000C, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.leftBackHardness(1))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported leftWaistHardness 1 and driver emit custom_capabilities.mattressHardness.leftWaistHardness(1)", + function() + local attr_report_data = { + { 0x000D, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.leftWaistHardness(1))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported leftHipHardness 1 and driver emit custom_capabilities.mattressHardness.leftHipHardness(1)", + function() + local attr_report_data = { + { 0x000E, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.leftHipHardness(1))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported rightBackHardness 1 and driver emit custom_capabilities.mattressHardness.rightBackHardness(1)", + function() + local attr_report_data = { + { 0x000F, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.rightBackHardness(1))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported rightWaistHardness 1 and driver emit custom_capabilities.mattressHardness.rightWaistHardness(1)", + function() + local attr_report_data = { + { 0x0010, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.rightWaistHardness(1))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported rightHipHardness 1 and driver emit custom_capabilities.mattressHardness.rightHipHardness(1)", + function() + local attr_report_data = { + { 0x0011, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.mattressHardness.rightHipHardness(1))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported yoga 3 and driver emit custom_capabilities.yoga.state.both()", + function() + local attr_report_data = { + { 0x0008, data_types.Uint8.ID, 3 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.state.both())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported yoga 2 and driver emit custom_capabilities.yoga.state.right()", + function() + local attr_report_data = { + { 0x0008, data_types.Uint8.ID, 2 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.state.right())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported yoga 1 and driver emit custom_capabilities.yoga.state.left()", + function() + local attr_report_data = { + { 0x0008, data_types.Uint8.ID, 1 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.state.left())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported yoga 0 and driver emit custom_capabilities.yoga.state.stop()", + function() + local attr_report_data = { + { 0x0008, data_types.Uint8.ID, 0 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.yoga.state.stop())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported ai_mode left false and driver emit custom_capabilities.ai_mode.left.off()", + function() + local attr_report_data = { + { 0x0006, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.ai_mode.left.off())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported ai_mode left true and driver emit custom_capabilities.ai_mode.left.on()", + function() + local attr_report_data = { + { 0x0006, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.ai_mode.left.on())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported ai_mode right true and driver emit custom_capabilities.ai_mode.right.on()", + function() + local attr_report_data = { + { 0x0007, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.ai_mode.right.on())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported ai_mode right false and driver emit custom_capabilities.ai_mode.right.off()", + function() + local attr_report_data = { + { 0x0007, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.ai_mode.right.off())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported inflationState false and driver emit custom_capabilities.auto_inflation.inflationState.off()", + function() + local attr_report_data = { + { 0x0009, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.auto_inflation.inflationState.off())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported inflationState true and driver emit custom_capabilities.auto_inflation.inflationState.on()", + function() + local attr_report_data = { + { 0x0009, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.auto_inflation.inflationState.on())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported strong_exp_mode false and driver emit custom_capabilities.strong_exp_mode.expState.off()", + function() + local attr_report_data = { + { 0x000a, data_types.Boolean.ID, false } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.strong_exp_mode.expState.off())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device reported strong_exp_mode true and driver emit custom_capabilities.strong_exp_mode.expState.on()", + function() + local attr_report_data = { + { 0x000a, data_types.Boolean.ID, true } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.strong_exp_mode.expState.on())) + end, + { + min_api_version = 14 + } +) + + +test.register_coroutine_test( + "capability leftControl on and driver send on ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.ai_mode.ID, component = "main", command ="leftControl" , args = {"on"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0006, MFG_CODE, data_types.Boolean, true) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability leftControl off and driver send off ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.ai_mode.ID, component = "main", command ="leftControl" , args = {"off"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0006, MFG_CODE, data_types.Boolean, false) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability rightControl on and driver send on ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.ai_mode.ID, component = "main", command ="rightControl" , args = {"on"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0007, MFG_CODE, data_types.Boolean, true) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability rightControl off and driver send off ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.ai_mode.ID, component = "main", command ="rightControl" , args = {"off"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0007, MFG_CODE, data_types.Boolean, false) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability auto_inflation stateControl on and driver send on ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.auto_inflation.ID, component = "main", command ="stateControl" , args = {"on"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0009, MFG_CODE, data_types.Boolean, true) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability auto_inflation stateControl off and driver send off ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.auto_inflation.ID, component = "main", command ="stateControl" , args = {"off"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0009, MFG_CODE, data_types.Boolean, false) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability strong_exp_mode stateControl on and driver send on ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.strong_exp_mode.ID, component = "main", command ="stateControl" , args = {"on"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x000a, MFG_CODE, data_types.Boolean, true) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability strong_exp_mode stateControl off and driver send off ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.strong_exp_mode.ID, component = "main", command ="stateControl" , args = {"off"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x000a, MFG_CODE, data_types.Boolean, false) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability left_control backControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="backControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.soft())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability waistControl backControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="waistControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0001, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftwaist.soft())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability left_control hipControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="hipControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0002, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.lefthip.soft())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability left_control backControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="backControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.hard())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability waistControl backControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="waistControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0001, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftwaist.hard())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability left_control hipControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="hipControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0002, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.lefthip.hard())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability right_control backControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="backControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0003, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightback.soft())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability right_control waistControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="waistControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0004, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightwaist.soft())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability right_control hipControl soft and driver send soft ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="hipControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0005, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.righthip.soft())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability right_control backControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="backControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0003, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightback.hard())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability right_control waistControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="waistControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0004, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.rightwaist.hard())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability right_control hipControl hard and driver send hard ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.right_control.ID, component = "main", command ="hipControl" , args = {"hard"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0005, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.right_control.righthip.hard())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability yoga stateControl left and driver send left ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"left"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0008, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability yoga stateControl right and driver send right ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"right"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0008, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability yoga stateControl stop and driver send stop ", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.yoga.ID, component = "main", command ="stateControl" , args = {"stop"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0008, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "capability left_control backControl soft emits idle event after delay", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = custom_capabilities.left_control.ID, component = "main", command ="backControl" , args = {"soft"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + 0x0000, MFG_CODE, data_types.Uint8, 0) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback.soft())) + test.wait_for_events() + + test.mock_time.advance_time(1) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + custom_capabilities.left_control.leftback("idle", { visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/fingerprints.yml b/drivers/SmartThings/zigbee-button/fingerprints.yml index c70cae5eac..119b8a18ee 100644 --- a/drivers/SmartThings/zigbee-button/fingerprints.yml +++ b/drivers/SmartThings/zigbee-button/fingerprints.yml @@ -34,6 +34,11 @@ zigbeeManufacturer: manufacturer: LUMI model: lumi.remote.b28ac1 deviceProfileName: aqara-double-buttons-mode + - id: "lumi/lumi.remote.rkba01" + deviceLabel: "Aqara Wireless Smart Knob H1" + manufacturer: LUMI + model: lumi.remote.rkba01 + deviceProfileName: aqara-knob-switch - id: "HEIMAN/SOS-EM" deviceLabel: HEIMAN Button manufacturer: HEIMAN @@ -220,7 +225,7 @@ zigbeeManufacturer: model: WB01 deviceProfileName: one-button-battery - id: eWeLink/SNZB-01P - deviceLabel: eWeLink Button + deviceLabel: Zigbee Wireless Switch manufacturer: eWeLink model: SNZB-01P deviceProfileName: one-button-battery @@ -243,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 @@ -262,6 +267,17 @@ zigbeeManufacturer: manufacturer: WALL HERO model: ACL-401SCA4 deviceProfileName: thirty-buttons + # SONOFF + - id: "SONOFF/SNZB-01M" + deviceLabel: SNZB-01M + manufacturer: SONOFF + model: SNZB-01M + deviceProfileName: four-buttons-battery + - id: "MultIR/MIR-SO100" + deviceLabel: MultiIR Smart button MIR-SO100 + manufacturer: MultIR + model: MIR-SO100 + deviceProfileName: one-button-battery-no-fw-update zigbeeGeneric: - id: "generic-button-sensor" deviceLabel: "Zigbee Generic Button" diff --git a/drivers/SmartThings/zigbee-button/profiles/aqara-knob-switch.yml b/drivers/SmartThings/zigbee-button/profiles/aqara-knob-switch.yml new file mode 100644 index 0000000000..1c197504bb --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/aqara-knob-switch.yml @@ -0,0 +1,19 @@ +name: aqara-knob-switch +components: + - id: main + capabilities: + - id: button + version: 1 + - id: knob + version: 1 + - id: batteryLevel + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Button +preferences: + - preferenceId: stse.knobSensitivity + explicit: true diff --git a/drivers/SmartThings/zigbee-button/profiles/one-button-battery-no-fw-update.yml b/drivers/SmartThings/zigbee-button/profiles/one-button-battery-no-fw-update.yml new file mode 100644 index 0000000000..98175a88ea --- /dev/null +++ b/drivers/SmartThings/zigbee-button/profiles/one-button-battery-no-fw-update.yml @@ -0,0 +1,12 @@ +name: one-button-battery-no-fw-update +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/zigbee-button/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-button/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-button/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..f9e21e49b9 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-SO100" } +} diff --git a/drivers/SmartThings/zigbee-button/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-button/src/MultiIR/init.lua new file mode 100644 index 0000000000..06d6d98f7e --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/MultiIR/init.lua @@ -0,0 +1,37 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local log = require "log" + +local IASZone = zcl_clusters.IASZone +local PRIVATE_CMD_ID = 0xF1 + +local function ias_zone_private_cmd_handler(self, device, zb_rx) + local cmd_data = zb_rx.body.zcl_body.body_bytes:byte(1) + if cmd_data == 0 then + device:emit_event(capabilities.button.button.pushed({state_change = true})) + elseif cmd_data == 1 then + device:emit_event(capabilities.button.button.double({state_change = true})) + elseif cmd_data == 0x80 then + device:emit_event(capabilities.button.button.held({state_change = true})) + else + log.info("ias_zone_private_cmd Unknown value",zb_rx.body.zcl_body.body_bytes:byte(1)) + end +end + +local MultiIR_Emergency_Button = { + NAME = "MultiIR Emergency Button", + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [PRIVATE_CMD_ID] = ias_zone_private_cmd_handler + } + } + }, + can_handle = require("MultiIR.can_handle") +} + +return MultiIR_Emergency_Button diff --git a/drivers/SmartThings/zigbee-button/src/aqara-knob/can_handle.lua b/drivers/SmartThings/zigbee-button/src/aqara-knob/can_handle.lua new file mode 100644 index 0000000000..a5a48f6cb9 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/aqara-knob/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device, ...) + local FINGERPRINTS = { mfr = "LUMI", model = "lumi.remote.rkba01" } + + if device:get_manufacturer() == FINGERPRINTS.mfr and device:get_model() == FINGERPRINTS.model then + return true, require("aqara-knob") + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-button/src/aqara-knob/init.lua b/drivers/SmartThings/zigbee-button/src/aqara-knob/init.lua new file mode 100644 index 0000000000..3b979baa24 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/aqara-knob/init.lua @@ -0,0 +1,141 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local button_utils = require "button_utils" + +local PowerConfiguration = clusters.PowerConfiguration +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 +local MFG_CODE = 0x115F +local MULTISTATE_INPUT_CLUSTER_ID = 0x0012 +local PRESENT_ATTRIBUTE_ID = 0x0055 +local ROTATION_MONITOR_ID = 0x0232 + +local AQARA_KNOB = { + ["lumi.remote.rkba01"] = { mfr = "LUMI", type = "CR2032", quantity = 2 }, -- Aqara Wireless Knob Switch H1 +} + +local function device_init(driver, device) + local configuration = { + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryVoltage.ID, + minimum_interval = 30, + maximum_interval = 3600, + data_type = PowerConfiguration.attributes.BatteryVoltage.base_type, + reportable_change = 1 + } + } + + battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) + for _, attribute in ipairs(configuration) do + device:add_configured_attribute(attribute) + end +end + +local function device_added(self, device) + local model = device:get_model() + local type = AQARA_KNOB[model].type or "CR2032" + local quantity = AQARA_KNOB[model].quantity or 1 + + device:emit_event(capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } })) + device:emit_event(capabilities.button.numberOfButtons({ value = 1 })) + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, + capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) + device:emit_event(capabilities.batteryLevel.battery.normal()) + device:emit_event(capabilities.batteryLevel.type(type)) + device:emit_event(capabilities.batteryLevel.quantity(quantity)) + device:emit_event(capabilities.knob.rotateAmount(0)) + device:emit_event(capabilities.knob.heldRotateAmount(0)) +end + +local function do_configure(driver, device) + device:configure() + -- Set manufacturer-specific attribute to enable "Operation Mode" for rotation reports + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1)) + device:emit_event(capabilities.knob.supportedAttributes({"rotateAmount", "heldRotateAmount"}, {state_change = true})) +end + +local function button_monitor_handler(driver, device, value, zb_rx) + local val = value.value + + if val == 1 then -- push + device:emit_event(capabilities.button.button.pushed({ state_change = true })) + elseif val == 2 then -- double push + device:emit_event(capabilities.button.button.double({ state_change = true })) + elseif val == 0 then -- down_hold + device:emit_event(capabilities.button.button.held({ state_change = true })) + end +end + +local function rotation_monitor_per_handler(driver, device, value, zb_rx) + local SENSITIVITY_KEY = "stse.knobSensitivity" + local SENSITIVITY_FACTORS = {0.5, 1.0, 2.0} + + local end_point = zb_rx.address_header.src_endpoint.value + local raw_val = utils.round(value.value) + + if raw_val > 0x7FFF then + raw_val = raw_val - 0x10000 + end + + local sensitivity = tonumber(device.preferences[SENSITIVITY_KEY]) + local factor = SENSITIVITY_FACTORS[sensitivity] or 1.0 + local intermediate_val = raw_val * factor + local sign = (intermediate_val > 0 and 1) or (intermediate_val < 0 and -1) or 0 + local val = math.floor(math.abs(intermediate_val) + 0.5) * sign + val = math.max(-100, math.min(100, val)) + + if val == 0 then + return + elseif end_point == 0x47 then -- normal + device:emit_event(capabilities.knob.rotateAmount({value = val}, {state_change = true})) + elseif end_point == 0x48 then -- press + device:emit_event(capabilities.knob.heldRotateAmount({value = val}, {state_change = true})) + end +end + +local function battery_level_handler(driver, device, value, zb_rx) + local voltage = value.value + local batteryLevel = "normal" + + if voltage <= 25 then + batteryLevel = "critical" + elseif voltage < 28 then + batteryLevel = "warning" + end + + device:emit_event(capabilities.batteryLevel.battery(batteryLevel)) +end + +local aqara_knob_switch_handler = { + NAME = "Aqara Wireless Knob Switch Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure + }, + zigbee_handlers = { + attr = { + [MULTISTATE_INPUT_CLUSTER_ID] = { + [PRESENT_ATTRIBUTE_ID] = button_monitor_handler + }, + [PRIVATE_CLUSTER_ID] = { + [ROTATION_MONITOR_ID] = rotation_monitor_per_handler + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryVoltage.ID] = battery_level_handler + }, + } + }, + can_handle = require("aqara-knob.can_handle"), +} + +return aqara_knob_switch_handler \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-button/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-button/src/aqara/can_handle.lua new file mode 100644 index 0000000000..3dc96661cd --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/aqara/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device) + local FINGERPRINTS = require "aqara.fingerprints" + if FINGERPRINTS[device:get_model()] and FINGERPRINTS[device:get_model()].mfr == device:get_manufacturer() then + return true, require("aqara") + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-button/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..83f7c77050 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/aqara/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + ["lumi.remote.b1acn02"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Mini Switch T1 + ["lumi.remote.acn003"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch E1 (Single Rocker) + ["lumi.remote.b186acn03"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch T1 (Single Rocker) + ["lumi.remote.b286acn03"] = { mfr = "LUMI", btn_cnt = 3, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch T1 (Double Rocker) + ["lumi.remote.b18ac1"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2450", quantity = 1 }, -- Aqara Wireless Remote Switch H1 (Single Rocker) + ["lumi.remote.b28ac1"] = { mfr = "LUMI", btn_cnt = 3, type = "CR2450", quantity = 1 } -- Aqara Wireless Remote Switch H1 (Double Rocker) +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/aqara/init.lua b/drivers/SmartThings/zigbee-button/src/aqara/init.lua index 1fb762cbbc..b9545e4721 100644 --- a/drivers/SmartThings/zigbee-button/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-button/src/aqara/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local clusters = require "st.zigbee.zcl.clusters" @@ -34,14 +24,7 @@ local MULTISTATE_INPUT_CLUSTER_ID = 0x0012 local PRESENT_ATTRIBUTE_ID = 0x0055 local COMP_LIST = { "button1", "button2", "all" } -local AQARA_REMOTE_SWITCH = { - ["lumi.remote.b1acn02"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Mini Switch T1 - ["lumi.remote.acn003"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch E1 (Single Rocker) - ["lumi.remote.b186acn03"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch T1 (Single Rocker) - ["lumi.remote.b286acn03"] = { mfr = "LUMI", btn_cnt = 3, type = "CR2032", quantity = 1 }, -- Aqara Wireless Remote Switch T1 (Double Rocker) - ["lumi.remote.b18ac1"] = { mfr = "LUMI", btn_cnt = 1, type = "CR2450", quantity = 1 }, -- Aqara Wireless Remote Switch H1 (Single Rocker) - ["lumi.remote.b28ac1"] = { mfr = "LUMI", btn_cnt = 3, type = "CR2450", quantity = 1 } -- Aqara Wireless Remote Switch H1 (Double Rocker) -} +local FINGERPRINTS = require "aqara.fingerprints" local configuration = { { @@ -65,7 +48,7 @@ local configuration = { local function present_value_attr_handler(driver, device, value, zb_rx) if value.value < 0xFF then local end_point = zb_rx.address_header.src_endpoint.value - local btn_evt_cnt = AQARA_REMOTE_SWITCH[device:get_model()].btn_cnt or 1 + local btn_evt_cnt = FINGERPRINTS[device:get_model()].btn_cnt or 1 local evt = capabilities.button.button.held({ state_change = true }) if value.value == 1 then evt = capabilities.button.button.pushed({ state_change = true }) @@ -102,6 +85,7 @@ local function battery_level_handler(driver, device, value, zb_rx) batteryLevel = "warning" end + -- Note that all aqara buttons use batteryLevel and not battery capability. if device:supports_capability_by_id(capabilities.battery.ID) then device:emit_event(capabilities.battery.battery(calc_battery_percentage(voltage))) elseif device:supports_capability_by_id(capabilities.batteryLevel.ID) then @@ -110,7 +94,7 @@ local function battery_level_handler(driver, device, value, zb_rx) end local function mode_switching_handler(driver, device, value, zb_rx) - local btn_evt_cnt = AQARA_REMOTE_SWITCH[device:get_model()].btn_cnt or 1 + local btn_evt_cnt = FINGERPRINTS[device:get_model()].btn_cnt or 1 local allow = device.preferences[MODE_CHANGE] or false if allow then local mode = device:get_field(MODE) or 1 @@ -139,14 +123,6 @@ local function mode_switching_handler(driver, device, value, zb_rx) end end -local is_aqara_products = function(opts, driver, device) - local isAqaraProducts = false - if AQARA_REMOTE_SWITCH[device:get_model()] and AQARA_REMOTE_SWITCH[device:get_model()].mfr == device:get_manufacturer() then - isAqaraProducts = true - end - return isAqaraProducts -end - local function device_init(driver, device) battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) if configuration ~= nil then @@ -157,11 +133,11 @@ local function device_init(driver, device) end local function added_handler(self, device) - local btn_evt_cnt = AQARA_REMOTE_SWITCH[device:get_model()].btn_cnt or 1 + local btn_evt_cnt = FINGERPRINTS[device:get_model()].btn_cnt or 1 local mode = device:get_field(MODE) or 0 local model = device:get_model() - local type = AQARA_REMOTE_SWITCH[device:get_model()].type or "CR2032" - local quantity = AQARA_REMOTE_SWITCH[device:get_model()].quantity or 1 + local type = FINGERPRINTS[device:get_model()].type or "CR2032" + local quantity = FINGERPRINTS[device:get_model()].quantity or 1 if mode == 0 then if model == "lumi.remote.b18ac1" or model == "lumi.remote.b28ac1" then @@ -233,7 +209,7 @@ local aqara_wireless_switch_handler = { } } }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_wireless_switch_handler diff --git a/drivers/SmartThings/zigbee-button/src/button_utils.lua b/drivers/SmartThings/zigbee-button/src/button_utils.lua index 4a8f7101e0..10ca4ab11a 100644 --- a/drivers/SmartThings/zigbee-button/src/button_utils.lua +++ b/drivers/SmartThings/zigbee-button/src/button_utils.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local log = require "log" diff --git a/drivers/SmartThings/zigbee-button/src/dimming-remote/can_handle.lua b/drivers/SmartThings/zigbee-button/src/dimming-remote/can_handle.lua new file mode 100644 index 0000000000..d3a469e214 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/dimming-remote/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zigbee_dimming_remote(opts, driver, device, ...) + local FINGERPRINTS = require("dimming-remote.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("dimming-remote") + end + end + return false +end + +return can_handle_zigbee_dimming_remote diff --git a/drivers/SmartThings/zigbee-button/src/dimming-remote/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/dimming-remote/fingerprints.lua new file mode 100644 index 0000000000..439694ebaf --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/dimming-remote/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIBEE_DIMMING_SWITCH_FINGERPRINTS = { + { mfr = "OSRAM", model = "LIGHTIFY Dimming Switch" }, + { mfr = "CentraLite", model = "3130" } +} + +return ZIBEE_DIMMING_SWITCH_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/dimming-remote/init.lua b/drivers/SmartThings/zigbee-button/src/dimming-remote/init.lua index d08c975632..54ca675ccd 100644 --- a/drivers/SmartThings/zigbee-button/src/dimming-remote/init.lua +++ b/drivers/SmartThings/zigbee-button/src/dimming-remote/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -22,19 +12,7 @@ local battery_defaults = require "st.zigbee.defaults.battery_defaults" local button_utils = require "button_utils" -local ZIBEE_DIMMING_SWITCH_FINGERPRINTS = { - { mfr = "OSRAM", model = "LIGHTIFY Dimming Switch" }, - { mfr = "CentraLite", model = "3130" } -} -local function can_handle_zigbee_dimming_remote(opts, driver, device, ...) - for _, fingerprint in ipairs(ZIBEE_DIMMING_SWITCH_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function button_pushed_handler(button_number) return function(self, device, value, zb_rx) @@ -84,7 +62,7 @@ local dimming_remote = { } } }, - can_handle = can_handle_zigbee_dimming_remote + can_handle = require("dimming-remote.can_handle"), } return dimming_remote diff --git a/drivers/SmartThings/zigbee-button/src/ewelink/can_handle.lua b/drivers/SmartThings/zigbee-button/src/ewelink/can_handle.lua new file mode 100644 index 0000000000..6a9426b75d --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/ewelink/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ewelink_button(opts, driver, device, ...) + local FINGERPRINTS = require("ewelink.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("ewelink") + end + end + return false +end + +return can_handle_ewelink_button diff --git a/drivers/SmartThings/zigbee-button/src/ewelink/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/ewelink/fingerprints.lua new file mode 100644 index 0000000000..3e21d092d3 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/ewelink/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local EWELINK_BUTTON_FINGERPRINTS = { + { mfr = "eWeLink", model = "WB01" }, + { mfr = "eWeLink", model = "SNZB-01P" } +} + +return EWELINK_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/ewelink/init.lua b/drivers/SmartThings/zigbee-button/src/ewelink/init.lua index 334dd79108..b503b68c64 100644 --- a/drivers/SmartThings/zigbee-button/src/ewelink/init.lua +++ b/drivers/SmartThings/zigbee-button/src/ewelink/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -19,19 +9,7 @@ local device_management = require "st.zigbee.device_management" local OnOff = clusters.OnOff local button = capabilities.button.button -local EWELINK_BUTTON_FINGERPRINTS = { - { mfr = "eWeLink", model = "WB01" }, - { mfr = "eWeLink", model = "SNZB-01P" } -} -local function can_handle_ewelink_button(opts, driver, device, ...) - for _, fingerprint in ipairs(EWELINK_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function do_configure(driver, device) device:configure() @@ -61,7 +39,7 @@ local ewelink_button = { } } }, - can_handle = can_handle_ewelink_button + can_handle = require("ewelink.can_handle"), } -return ewelink_button \ No newline at end of file +return ewelink_button diff --git a/drivers/SmartThings/zigbee-button/src/ezviz/can_handle.lua b/drivers/SmartThings/zigbee-button/src/ezviz/can_handle.lua new file mode 100644 index 0000000000..b046b8966c --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/ezviz/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_ezviz_button = function(opts, driver, device) + + local EZVIZ_PRIVATE_BUTTON_CLUSTER = 0xFE05 + local EZVIZ_PRIVATE_STANDARD_CLUSTER = 0xFE00 + local EZVIZ_MFR = "EZVIZ" + + local support_button_cluster = device:supports_server_cluster(EZVIZ_PRIVATE_BUTTON_CLUSTER) + local support_standard_cluster = device:supports_server_cluster(EZVIZ_PRIVATE_STANDARD_CLUSTER) + if device:get_manufacturer() == EZVIZ_MFR and support_button_cluster and support_standard_cluster then + return true, require("ezviz") + end + return false +end + +return is_ezviz_button diff --git a/drivers/SmartThings/zigbee-button/src/ezviz/init.lua b/drivers/SmartThings/zigbee-button/src/ezviz/init.lua index 5b630843a2..5bea38ffb4 100644 --- a/drivers/SmartThings/zigbee-button/src/ezviz/init.lua +++ b/drivers/SmartThings/zigbee-button/src/ezviz/init.lua @@ -1,31 +1,12 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local EZVIZ_PRIVATE_BUTTON_CLUSTER = 0xFE05 -local EZVIZ_PRIVATE_STANDARD_CLUSTER = 0xFE00 local EZVIZ_PRIVATE_BUTTON_ATTRIBUTE = 0x0000 -local EZVIZ_MFR = "EZVIZ" -local is_ezviz_button = function(opts, driver, device) - local support_button_cluster = device:supports_server_cluster(EZVIZ_PRIVATE_BUTTON_CLUSTER) - local support_standard_cluster = device:supports_server_cluster(EZVIZ_PRIVATE_STANDARD_CLUSTER) - if device:get_manufacturer() == EZVIZ_MFR and support_button_cluster and support_standard_cluster then - return true - end -end local ezviz_private_cluster_button_handler = function(driver, device, zb_rx) local event @@ -53,6 +34,6 @@ local ezviz_button_handler = { } } }, - can_handle = is_ezviz_button + can_handle = require("ezviz.can_handle"), } -return ezviz_button_handler \ No newline at end of file +return ezviz_button_handler diff --git a/drivers/SmartThings/zigbee-button/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-button/src/frient/can_handle.lua new file mode 100644 index 0000000000..924c595fd3 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/frient/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and (device:get_model() == "SBTZB-110" or device:get_model() == "MBTZB-110") then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/frient/init.lua b/drivers/SmartThings/zigbee-button/src/frient/init.lua index 17b963548d..bf622cd694 100644 --- a/drivers/SmartThings/zigbee-button/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-button/src/frient/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -232,8 +222,6 @@ local frient_button = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" and (device:get_model() == "SBTZB-110" or device:get_model() == "MBTZB-110") - end + can_handle = require("frient.can_handle"), } return frient_button diff --git a/drivers/SmartThings/zigbee-button/src/init.lua b/drivers/SmartThings/zigbee-button/src/init.lua index a61ff415a7..cb1565ea74 100644 --- a/drivers/SmartThings/zigbee-button/src/init.lua +++ b/drivers/SmartThings/zigbee-button/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -140,23 +130,13 @@ local zigbee_button_driver_template = { } } }, - sub_drivers = { - require("aqara"), - require("pushButton"), - require("frient"), - require("zigbee-multi-button"), - require("dimming-remote"), - require("iris"), - require("samjin"), - require("ewelink"), - require("thirdreality"), - require("ezviz") - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { added = added_handler, }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_button_driver_template, zigbee_button_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-button/src/iris/can_handle.lua b/drivers/SmartThings/zigbee-button/src/iris/can_handle.lua new file mode 100644 index 0000000000..618bc6eb5a --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/iris/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_iris_button(opts, driver, device, ...) + local FINGERPRINTS = require("iris.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("iris") + end + end + return false +end + +return can_handle_iris_button diff --git a/drivers/SmartThings/zigbee-button/src/iris/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/iris/fingerprints.lua new file mode 100644 index 0000000000..4ae1761e06 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/iris/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IRIS_BUTTON_FINGERPRINTS = { + { mfr = "CentraLite", model = "3455-L" }, + { mfr = "CentraLite", model = "3460-L" } +} + +return IRIS_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/iris/init.lua b/drivers/SmartThings/zigbee-button/src/iris/init.lua index 157e766748..a6eac49870 100644 --- a/drivers/SmartThings/zigbee-button/src/iris/init.lua +++ b/drivers/SmartThings/zigbee-button/src/iris/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -21,19 +11,7 @@ local device_management = require "st.zigbee.device_management" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local button_utils = require "button_utils" -local IRIS_BUTTON_FINGERPRINTS = { - { mfr = "CentraLite", model = "3455-L" }, - { mfr = "CentraLite", model = "3460-L" } -} -local function can_handle_iris_button(opts, driver, device, ...) - for _, fingerprint in ipairs(IRIS_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function button_pressed_handler(self, device, value, zb_rx) button_utils.init_button_press(device) @@ -94,7 +72,7 @@ local iris_button = { } } }, - can_handle = can_handle_iris_button + can_handle = require("iris.can_handle"), } return iris_button diff --git a/drivers/SmartThings/zigbee-button/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-button/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-button/src/pushButton/can_handle.lua b/drivers/SmartThings/zigbee-button/src/pushButton/can_handle.lua new file mode 100644 index 0000000000..f25592fb01 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/pushButton/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function pushButton_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "HEIMAN" and device:get_model() == "SOS-EM" then + return true, require("pushButton") + end + return false +end + +return pushButton_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/pushButton/init.lua b/drivers/SmartThings/zigbee-button/src/pushButton/init.lua index 6fec59d69e..170307a600 100644 --- a/drivers/SmartThings/zigbee-button/src/pushButton/init.lua +++ b/drivers/SmartThings/zigbee-button/src/pushButton/init.lua @@ -1,27 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- Copyright 2020 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. local capabilities = require "st.capabilities" local button_utils = require "button_utils" @@ -38,9 +18,7 @@ local push_button = { added = added_handler, }, sub_drivers = {}, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "HEIMAN" and device:get_model() == "SOS-EM" - end + can_handle = require("pushButton.can_handle"), } return push_button diff --git a/drivers/SmartThings/zigbee-button/src/samjin/can_handle.lua b/drivers/SmartThings/zigbee-button/src/samjin/can_handle.lua new file mode 100644 index 0000000000..0886bedb9c --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/samjin/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function samjin_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Samjin" and device:get_model() == "button" then + return true, require("samjin") + end + return false +end + +return samjin_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/samjin/init.lua b/drivers/SmartThings/zigbee-button/src/samjin/init.lua index 116e6aaa80..bd254091a6 100644 --- a/drivers/SmartThings/zigbee-button/src/samjin/init.lua +++ b/drivers/SmartThings/zigbee-button/src/samjin/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" @@ -28,9 +18,7 @@ local samjin_button = { lifecycle_handlers = { init = init_handler }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Samjin" and device:get_model() == "button" - end + can_handle = require("samjin.can_handle"), } return samjin_button diff --git a/drivers/SmartThings/zigbee-button/src/st/zigbee/zdo/init.lua b/drivers/SmartThings/zigbee-button/src/st/zigbee/zdo/init.lua index 9d009d47cb..3d832ac180 100644 --- a/drivers/SmartThings/zigbee-button/src/st/zigbee/zdo/init.lua +++ b/drivers/SmartThings/zigbee-button/src/st/zigbee/zdo/init.lua @@ -1,16 +1,5 @@ --- Copyright 2021 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. +-- Copyright 2021 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local data_types = require "st.zigbee.data_types" local utils = require "st.zigbee.utils" local zdo_commands = require "st.zigbee.zdo.commands" @@ -152,4 +141,4 @@ end setmetatable(zdo_messages.ZdoMessageBody, { __call = zdo_messages.ZdoMessageBody.from_values }) -return zdo_messages \ No newline at end of file +return zdo_messages diff --git a/drivers/SmartThings/zigbee-button/src/sub_drivers.lua b/drivers/SmartThings/zigbee-button/src/sub_drivers.lua new file mode 100644 index 0000000000..8aa36d9ba0 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/sub_drivers.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("aqara-knob"), + lazy_load_if_possible("pushButton"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("zigbee-multi-button"), + lazy_load_if_possible("dimming-remote"), + lazy_load_if_possible("iris"), + lazy_load_if_possible("samjin"), + lazy_load_if_possible("ewelink"), + lazy_load_if_possible("thirdreality"), + lazy_load_if_possible("ezviz"), + lazy_load_if_possible("MultiIR"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-button/src/test/test_SLED_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_SLED_button.lua index fecbeeabb7..288d531148 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_SLED_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_SLED_button.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local t_utils = require "integration_test.utils" @@ -59,7 +48,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("button3", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -80,7 +72,10 @@ test.register_coroutine_test( mock_device:generate_test_message("button3", button_attr.held({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -92,7 +87,10 @@ test.register_coroutine_test( zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_aduro_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_aduro_button.lua index 2736ea3d96..d44d73da7a 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_aduro_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_aduro_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -102,7 +91,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -137,7 +129,10 @@ test.register_coroutine_test( Level.ID, 3) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -182,7 +177,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_aqara_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_aqara_button.lua index 838b9545fb..da3fe73c48 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_aqara_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_aqara_button.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -32,6 +21,21 @@ local PRIVATE_ATTRIBUTE_ID_ALIVE = 0x00F7 local MODE_CHANGE = "stse.allowOperationModeChange" local COMP_LIST = { "button1", "button2", "all" } + +local mock_device_h1_single = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-single-button-mode.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.remote.b18ac1", + server_clusters = { 0x0001, 0x0012 } + } + } + } +) + local mock_device_e1 = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("one-button-batteryLevel.yml"), @@ -62,6 +66,7 @@ local mock_device_h1_double_rocker = test.mock_device.build_test_zigbee_device( zigbee_test_utils.prepare_zigbee_env_info() local function test_init() + test.mock_device.add_test_device(mock_device_h1_single) test.mock_device.add_test_device(mock_device_e1) test.mock_device.add_test_device(mock_device_h1_double_rocker) end @@ -92,7 +97,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message(COMP_LIST[i], capabilities.button.button.pushed({ state_change = false }))) end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -121,7 +129,10 @@ test.register_coroutine_test( MFG_CODE, data_types.Uint8, 2) }) mock_device_e1:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) @@ -153,7 +164,10 @@ test.register_coroutine_test( PRIVATE_ATTRIBUTE_ID_T1, MFG_CODE, data_types.Uint8, 1) }) mock_device_h1_double_rocker:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -171,7 +185,10 @@ test.register_coroutine_test( capabilities.button.button.pushed({ state_change = true }))) test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -189,7 +206,10 @@ test.register_coroutine_test( capabilities.button.button.double({ state_change = true }))) test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("button1", capabilities.button.button.double({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -207,7 +227,10 @@ test.register_coroutine_test( capabilities.button.button.held({ state_change = true }))) test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message("button1", capabilities.button.button.held({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -223,6 +246,9 @@ test.register_message_test( direction = "send", message = mock_device_e1:generate_test_message("main", capabilities.batteryLevel.battery("normal")) } + }, + { + min_api_version = 14 } ) test.register_message_test( @@ -238,6 +264,9 @@ test.register_message_test( direction = "send", message = mock_device_e1:generate_test_message("main", capabilities.batteryLevel.battery("warning")) } + }, + { + min_api_version = 14 } ) test.register_message_test( @@ -253,6 +282,9 @@ test.register_message_test( direction = "send", message = mock_device_e1:generate_test_message("main", capabilities.batteryLevel.battery("critical")) } + }, + { + min_api_version = 14 } ) @@ -294,7 +326,32 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device_h1_double_rocker:generate_test_message(COMP_LIST[i], capabilities.button.button.pushed({ state_change = false }))) end - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Handle added lifecycle - H1 single rocker (sets mode=1)", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_h1_single.id, "added" }) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }))) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.batteryLevel.battery.normal())) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.batteryLevel.type("CR2450"))) + test.socket.capability:__expect_send(mock_device_h1_single:generate_test_message("main", + capabilities.batteryLevel.quantity(1))) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_aqara_knob_switch.lua b/drivers/SmartThings/zigbee-button/src/test/test_aqara_knob_switch.lua new file mode 100644 index 0000000000..69ae937aff --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_aqara_knob_switch.lua @@ -0,0 +1,287 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + + +local MULTISTATE_INPUT_CLUSTER_ID = 0x0012 +local PRESENT_ATTRIBUTE_ID = 0x0055 +local PowerConfiguration = clusters.PowerConfiguration + +local MFG_CODE = 0x115F +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-knob-switch.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.remote.rkba01", + server_clusters = { 0x0001 } + } + } + } +) +mock_device:set_field("preferences", {["stse.knobSensitivity"] = 2}, {persist = true}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed", "held", "double" }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery.normal())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.type("CR2032"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.quantity(2))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount(0))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.heldRotateAmount(0))) + end +) + +test.register_coroutine_test( + "Handle doConfigure lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 3600, 1) + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, + MFG_CODE, + data_types.Uint8, 1) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.supportedAttributes({"rotateAmount", "heldRotateAmount"}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - normal", + function() + local attr_report_data = { + { 0x0232, data_types.Uint16.ID, 0x0001 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = 1}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - max clamp at 100", + function() + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 200 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = 100}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - min clamp at -100", + function() + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 65386 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = -100}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - sensitivity 0.5x", + function() + mock_device:set_field("preferences", {["stse.knobSensitivity"] = 1}) + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 3 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = 3}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - sensitivity 2.0x", + function() + mock_device:set_field("preferences", {["stse.knobSensitivity"] = 3}) + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 5 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x47) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.rotateAmount({value = 5}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - held rotate amount", + function() + mock_device:set_field("preferences", {["stse.knobSensitivity"] = 2}) + local attr_report_data = {{ 0x0232, data_types.Uint16.ID, 15 }} + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x48) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.heldRotateAmount({value = 15}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "rotation_monitor_per_handler - press", + function() + local attr_report_data = { + { 0x0232, data_types.Uint16.ID, 0x0001 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, + attr_report_data, MFG_CODE):from_endpoint(0x48) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.knob.heldRotateAmount({value = 1}, {state_change = true}))) + end +) + +test.register_coroutine_test( + "Reported button should be handled: pushed true", + function() + local attr_report_data = { + { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0001 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end +) + +test.register_coroutine_test( + "Reported button should be handled: double true", + function() + local attr_report_data = { + { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0002 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.double({ state_change = true }))) + end +) + +test.register_coroutine_test( + "Reported button should be handled: held true", + function() + local attr_report_data = { + { PRESENT_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, MULTISTATE_INPUT_CLUSTER_ID, + attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.held({ state_change = true }))) + end +) + +test.register_message_test( + "Battery Level - Normal", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery("normal")) + } + } +) +test.register_message_test( + "Battery Level - Warning", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 27) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery("warning")) + } + } +) +test.register_message_test( + "Battery Level - Critical", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 20) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery("critical")) + } + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_centralite_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_centralite_button.lua index 6c2e46f724..c08a42e4c0 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_centralite_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_centralite_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -104,7 +93,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) @@ -158,7 +150,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -194,7 +189,10 @@ test.register_coroutine_test( OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -205,7 +203,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -250,7 +251,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_dimming_remote.lua b/drivers/SmartThings/zigbee-button/src/test/test_dimming_remote.lua index 4ba87cb461..17170106a7 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_dimming_remote.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_dimming_remote.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -64,7 +53,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -92,7 +84,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -120,7 +115,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -159,7 +157,10 @@ test.register_coroutine_test( ) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -170,7 +171,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -269,7 +273,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ewelink_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_ewelink_button.lua index d08e38391a..61e30bfaee 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ewelink_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ewelink_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -73,7 +62,10 @@ test.register_coroutine_test( mock_device.id, TemperatureMeasurement.attributes.MinMeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -85,7 +77,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -98,7 +93,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button.button.double({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -111,7 +109,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button.button.held({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -148,7 +149,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button.button.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ezviz_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_ezviz_button.lua index 77b6768149..b012f30a8d 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ezviz_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ezviz_button.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -64,6 +53,9 @@ test.register_message_test( direction = "send", message = mock_device_ezviz_button:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -105,6 +97,9 @@ test.register_message_test( direction = "send", message = mock_device_ezviz_button:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -121,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_device_ezviz_button:generate_test_message("main", capabilities.button.button.double({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -137,6 +135,9 @@ test.register_message_test( direction = "send", message = mock_device_ezviz_button:generate_test_message("main", capabilities.button.button.held({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -170,7 +171,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_send({ mock_device_ezviz_button.id, ZoneStatusAttribute:read(mock_device_ezviz_button) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -193,7 +197,10 @@ test.register_coroutine_test( mock_device_ezviz_button:generate_test_message("main", capabilities.button.button.pushed({ state_change = false })) ) - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_frient_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_frient_button.lua index 3df15b9f08..6dc9b979c1 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_frient_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_frient_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -97,6 +86,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -119,7 +111,11 @@ test.register_message_test("Refresh should read all necessary attributes", { direction = "send", message = {mock_device.id, BasicInput.attributes.PresentValue:read(mock_device)} }, -}) +}, +{ + min_api_version = 14 +} +) test.register_coroutine_test("panicAlarm should be triggered and cleared", function() @@ -151,7 +147,11 @@ test.register_coroutine_test("panicAlarm should be triggered and cleared", funct test.socket.capability:__expect_send(mock_device_panic:generate_test_message("main", panicAlarm.clear({value = "clear", state_change = true}))) test.wait_for_events() -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test( "Battery Voltage test cases", @@ -182,7 +182,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = { displayed = false }}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({value = 1}))) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -221,7 +224,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, BasicInput.ID, 0x8000, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 65535):to_endpoint(0x20)}) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test("info_changed for OnOff cluster attributes should run properly", @@ -245,7 +251,11 @@ function() buttonDelay_msg.body.zcl_header.frame_ctrl.value = 0x0C buttonDelay_msg.address_header.dest_endpoint.value = 0x20 test.socket.zigbee:__expect_send({mock_device.id, buttonDelay_msg}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test(" Configuration and Switching to button-profile-panic-frient deviceProfile should be triggered", function() test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") @@ -274,7 +284,11 @@ test.register_coroutine_test(" Configuration and Switching to button-profile-pan end -- Unable to check if the emit went through successfully due to the framework limitations in swapping mock device's deviceProfile --test.socket.capability:__expect_send({mock_device.id, capabilities.panicAlarm.panicAlarm.clear({state_change = true})}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Switching from button-profile-panic-frient to button-profile-frient should work", function() test.socket.device_lifecycle:__queue_receive(mock_device_panic:generate_info_changed( @@ -286,7 +300,11 @@ test.register_coroutine_test("Switching from button-profile-panic-frient to butt )) mock_device_panic:expect_metadata_update({ profile = "button-profile-frient" }) test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic,BasicInput.ID,0x8000,DEVELCO_MANUFACTURER_CODE,data_types.Uint16,0xFFFF)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("New preferences after switching the profile should work", function() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -304,6 +322,10 @@ test.register_coroutine_test("New preferences after switching the profile should test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic, IASZone.ID,0x8003,DEVELCO_MANUFACTURER_CODE,data_types.Uint16, 300)}) test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic, IASZone.ID,0x8004,DEVELCO_MANUFACTURER_CODE,data_types.Uint16, 20)}) test.socket.zigbee:__expect_send({mock_device_panic.id, cluster_base.write_manufacturer_specific_attribute(mock_device_panic, IASZone.ID,0x8005,DEVELCO_MANUFACTURER_CODE,data_types.Enum8, 1)}) -end) +end, +{ + min_api_version = 14 +} +) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_heiman_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_heiman_button.lua index f0604a0cd8..7142b8ad8c 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_heiman_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_heiman_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -101,7 +90,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -133,7 +125,10 @@ test.register_coroutine_test( mock_device_hs6ssb:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -177,7 +172,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_add_hub_to_group(0x0011) test.socket.zigbee:__expect_add_hub_to_group(0x0012) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -198,7 +196,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_add_hub_to_group(0x0012) test.socket.zigbee:__expect_add_hub_to_group(0x0013) mock_device_hs6ssb:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -276,7 +277,10 @@ test.register_coroutine_test( Basic.attributes.DeviceEnabled:write(mock_device, true) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -307,7 +311,10 @@ test.register_coroutine_test( Basic.attributes.DeviceEnabled:write(mock_device_hs6ssb, true) }) mock_device_hs6ssb:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -352,7 +359,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -397,7 +407,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device_hs6ssb.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ikea_on_off.lua b/drivers/SmartThings/zigbee-button/src/test/test_ikea_on_off.lua index 9479358793..d3d1d75c67 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ikea_on_off.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ikea_on_off.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -84,7 +73,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -125,7 +117,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -146,7 +141,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -205,7 +203,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -301,7 +302,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ikea_open_close.lua b/drivers/SmartThings/zigbee-button/src/test/test_ikea_open_close.lua index 7b41684c5a..56a8d3c382 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ikea_open_close.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ikea_open_close.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -64,7 +53,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", (button_attr.pushed({ state_change = true }))) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -106,7 +98,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -127,7 +122,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -149,7 +147,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_add_hub_to_group(0x0000) test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -244,7 +245,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_ikea_remote_control.lua b/drivers/SmartThings/zigbee-button/src/test/test_ikea_remote_control.lua index 6b16ed842c..92360b9b5d 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_ikea_remote_control.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_ikea_remote_control.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -118,7 +107,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", (button_attr.held({ state_change = true }))) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -160,7 +152,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -182,7 +177,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -286,7 +284,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -302,7 +303,10 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) } + }, + { + min_api_version = 14 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_iris_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_iris_button.lua index f28e47dece..14284ca482 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_iris_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_iris_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -56,7 +45,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -69,7 +61,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -108,7 +103,10 @@ test.register_coroutine_test( IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -123,7 +121,10 @@ test.register_coroutine_test( mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -174,7 +175,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -192,7 +196,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -210,7 +217,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -228,7 +238,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -246,7 +259,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_linxura_aura_smart_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_linxura_aura_smart_button.lua index 4b9ca6dfd9..b2eecc400c 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_linxura_aura_smart_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_linxura_aura_smart_button.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -66,7 +55,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -82,7 +74,10 @@ test.register_coroutine_test( ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -98,7 +93,10 @@ test.register_coroutine_test( ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) @@ -115,8 +113,11 @@ test.register_coroutine_test( ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_linxura_smart_controller_4x_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_linxura_smart_controller_4x_button.lua index a53d5f3851..406bcd018f 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_linxura_smart_controller_4x_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_linxura_smart_controller_4x_button.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -66,7 +55,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -82,7 +74,10 @@ test.register_coroutine_test( ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -98,7 +93,10 @@ test.register_coroutine_test( ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) @@ -115,8 +113,11 @@ test.register_coroutine_test( ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_multiir_smart_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_multiir_smart_button.lua new file mode 100644 index 0000000000..84b974e988 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_multiir_smart_button.lua @@ -0,0 +1,131 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local IASZone = clusters.IASZone +local PRIVATE_CMD_ID = 0xF1 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("one-button-battery-no-fw-update.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "MultIR", + model = "MIR-SO100", + server_clusters = {0x0000, 0x0001, 0x0003, 0x0020, 0x0500, 0x0B05} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + + + +test.register_coroutine_test( + "added lifecycle event", + function() + -- The initial button pushed event should be send during the device's first time onboarding + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed","held","double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send({ + mock_device.id, + { + capability_id = "button", component_id = "main", + attribute_id = "button", state = { value = "pushed" } + } + }) + -- Avoid sending the initial button pushed event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed","held","double" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "IASZone cmd 0xF1 0x00 are handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, IASZone.ID, PRIVATE_CMD_ID, 0x0000, "\x00", 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) + } + } +) + +test.register_message_test( + "IASZone cmd 0xF1 0x01 are handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, IASZone.ID, PRIVATE_CMD_ID, 0x0000, "\x01", 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.double({state_change = true})) + } + } +) + +test.register_message_test( + "IASZone cmd 0xF1 0x80 are handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, IASZone.ID, PRIVATE_CMD_ID, 0x0000, "\x80", 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.button.button.held({state_change = true})) + } + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_push_only_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_push_only_button.lua index ed046c5781..527c15ade7 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_push_only_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_push_only_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -57,6 +46,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) test.register_message_test( @@ -67,6 +59,9 @@ test.register_message_test( direction = "receive", message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0000) } } + }, + { + min_api_version = 14 } ) @@ -83,6 +78,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -99,6 +97,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -161,7 +162,10 @@ test.register_coroutine_test( IASZone.attributes.ZoneStatus:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -213,7 +217,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_robb_4x_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_robb_4x_button.lua index 554decefc1..0f014d8b41 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_robb_4x_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_robb_4x_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -90,7 +79,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -130,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.up_hold({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -172,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.down_hold({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -212,7 +210,10 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_add_hub_to_group(0xE902) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -323,7 +324,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -362,6 +366,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -377,7 +384,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_robb_8x_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_robb_8x_button.lua index 67554e92a0..c073b7e051 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_robb_8x_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_robb_8x_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -135,7 +124,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -209,6 +201,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.up_hold({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -287,6 +282,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.button.button.down_hold({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -329,7 +327,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_add_hub_to_group(0xE903) test.socket.zigbee:__expect_add_hub_to_group(0xE904) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -441,7 +442,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -480,6 +484,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -495,7 +502,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_samjin_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_samjin_button.lua index cf3bd7fe3d..be5af30cbc 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_samjin_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_samjin_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -99,7 +88,10 @@ test.register_coroutine_test( IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -146,7 +138,10 @@ test.register_coroutine_test( -- mock_device.id, -- IASZone.attributes.ZoneStatus:read(mock_device) -- }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_shinasystem_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_shinasystem_button.lua index 6f677e80b7..2a3f825ac9 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_shinasystem_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_shinasystem_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -94,7 +83,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) @@ -148,7 +140,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.held({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) @@ -202,7 +197,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", button_attr.double({ state_change = true })) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -239,7 +237,10 @@ test.register_coroutine_test( Groups.commands.AddGroup(mock_device, 0x0000) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -284,7 +285,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_1_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_1_button.lua index 83e9fec30d..f5f91aec73 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_1_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_1_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -74,7 +63,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -122,7 +114,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -143,7 +138,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -202,7 +200,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -247,7 +248,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_4_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_4_button.lua index c0329f5561..bd4d767f2f 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_4_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_somfy_situo_4_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local capabilities = require "st.capabilities" @@ -103,7 +92,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -169,7 +161,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("button11", (button_attr.pushed({ state_change = true }))) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -190,7 +185,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -235,7 +233,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-button/src/test/test_sonoff_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_sonoff_button.lua new file mode 100644 index 0000000000..4e02098b0b --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/test/test_sonoff_button.lua @@ -0,0 +1,279 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local zb_const = require "st.zigbee.constants" +local messages = require "st.zigbee.messages" +local data_types = require "st.zigbee.data_types" +local zcl_messages = require "st.zigbee.zcl" +local report_attr = require "st.zigbee.zcl.global_commands.report_attribute" + +local SONOFF_PRIVATE_BUTTON_CLUSTER = 0xFC12 +local SONOFF_PRIVATE_ATTR = 0x0000 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("four-buttons-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SNZB-01M", + server_clusters = { 0x0001, 0xFC12 } + }, + [2] = { + id = 2, + manufacturer = "SONOFF", + model = "SNZB-01M", + server_clusters = { 0x0001, 0xFC12 } + }, + [3] = { + id = 3, + manufacturer = "SONOFF", + model = "SNZB-01M", + server_clusters = { 0x0001, 0xFC12 } + }, + [4] = { + id = 4, + manufacturer = "SONOFF", + model = "SNZB-01M", + server_clusters = { 0x0001, 0xFC12 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function build_test_attr_report(device, endpoint, value) + local report_body = report_attr.ReportAttribute({ + report_attr.ReportAttributeAttributeRecord(SONOFF_PRIVATE_ATTR, data_types.Uint8.ID, value) + }) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(report_body.ID) + }) + local addrh = messages.AddressHeader( + device:get_short_address(), + endpoint, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + SONOFF_PRIVATE_BUTTON_CLUSTER + ) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = report_body + }) + + return messages.ZigbeeMessageRx({ + address_header = addrh, + body = message_body + }) +end + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.button.numberOfButtons({ value = 4 }, { visibility = { displayed = false } }) + ) + ) + + -- Check initial events for button 1 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button1", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button1", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + + -- Check initial events for button 2 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button2", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button2", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + + -- Check initial events for button 3 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button3", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button3", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + + -- Check initial events for button 4 + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button4", + capabilities.button.supportedButtonValues({ "pushed", "double", "held", "pushed_3x" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "button4", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false })) + ) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Button pushed message should generate event", + function() + -- 0xFC12, 0x0000, 0x01 = pushed + local attr_report = build_test_attr_report(mock_device, 1, data_types.Uint8(0x01)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Button double message should generate event", + function() + -- 0xFC12, 0x0000, 0x02 = double + local attr_report = build_test_attr_report(mock_device, 1, data_types.Uint8(0x02)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", capabilities.button.button.double({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.double({ state_change = true })) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Button held message should generate event", + function() + -- 0xFC12, 0x0000, 0x03 = held + local attr_report = build_test_attr_report(mock_device, 1, data_types.Uint8(0x03)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", capabilities.button.button.held({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.held({ state_change = true })) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Button pushed_3x message should generate event", + function() + -- 0xFC12, 0x0000, 0x04 = pushed_3x + local attr_report = build_test_attr_report(mock_device, 1, data_types.Uint8(0x04)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", capabilities.button.button.pushed_3x({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.pushed_3x({ state_change = true })) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Button 2 pushed message should generate event on button2 component", + function() + -- Endpoint 2 test + local attr_report = build_test_attr_report(mock_device, 2, data_types.Uint8(0x01)) + + test.socket.zigbee:__queue_receive({ mock_device.id, attr_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button2", capabilities.button.button.pushed({ state_change = true })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Battery percentage report should generate event", + function() + local battery_report = clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 180) + + test.socket.zigbee:__queue_receive({ mock_device.id, battery_report }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.battery.battery(90)) + ) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-button/src/test/test_thirdreality_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_thirdreality_button.lua index bdb8e51bf6..7c9472df39 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_thirdreality_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_thirdreality_button.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local data_types = require "st.zigbee.data_types" @@ -43,7 +46,10 @@ test.register_coroutine_test( capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -57,7 +63,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0x0012, attr_report_data, 0x110A) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -71,7 +80,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0x0012, attr_report_data, 0x110A) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.double({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -85,7 +97,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0x0012, attr_report_data, 0x110A) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.held({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_vimar_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_vimar_button.lua index e1d35fb6bc..f1f4413429 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_vimar_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_vimar_button.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -86,7 +75,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +93,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -116,7 +111,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -139,7 +137,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.up({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -162,7 +163,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.up({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -207,7 +211,10 @@ test.register_coroutine_test( end mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_wallhero_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_wallhero_button.lua index e62cbd7055..d4fcdd87f1 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_wallhero_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_wallhero_button.lua @@ -1,16 +1,5 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -315,7 +304,10 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 0x1F) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -353,7 +345,10 @@ test.register_coroutine_test( test.socket:set_time_advance_per_select(0.1) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_zigbee_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_zigbee_button.lua index 97feca9121..a854809234 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_zigbee_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_zigbee_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -48,6 +37,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -64,6 +56,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -80,6 +75,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.double({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -91,6 +89,9 @@ test.register_message_test( direction = "receive", message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0000) } } + }, + { + min_api_version = 14 } ) @@ -107,6 +108,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -123,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.held({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -139,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", button_attr.double({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -155,6 +165,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -172,6 +185,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) } + }, + { + min_api_version = 14 } ) @@ -195,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -275,7 +294,10 @@ test.register_coroutine_test( TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -349,7 +371,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_zigbee_ecosmart_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_zigbee_ecosmart_button.lua index 8f956e9203..cc75674c85 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_zigbee_ecosmart_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_zigbee_ecosmart_button.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -59,6 +48,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -104,7 +96,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -126,7 +121,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -141,7 +139,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -164,7 +165,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -206,7 +210,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", button_attr.pushed({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -252,7 +259,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_add_hub_to_group(0x4003) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/test/test_zunzunbee_8_button.lua b/drivers/SmartThings/zigbee-button/src/test/test_zunzunbee_8_button.lua index a92e1c6285..5e288c6344 100644 --- a/drivers/SmartThings/zigbee-button/src/test/test_zunzunbee_8_button.lua +++ b/drivers/SmartThings/zigbee-button/src/test/test_zunzunbee_8_button.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -90,7 +79,10 @@ test.register_coroutine_test( }) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -148,7 +140,10 @@ test.register_coroutine_test( IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -173,7 +168,10 @@ test.register_coroutine_test( ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) @@ -199,8 +197,11 @@ test.register_coroutine_test( ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-button/src/thirdreality/can_handle.lua b/drivers/SmartThings/zigbee-button/src/thirdreality/can_handle.lua new file mode 100644 index 0000000000..77d97bb6a5 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/thirdreality/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function thirdreality_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Third Reality, Inc" and device:get_model() == "3RSB22BZ" then + return true, require("thirdreality") + end + return false +end + +return thirdreality_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/thirdreality/init.lua b/drivers/SmartThings/zigbee-button/src/thirdreality/init.lua index c4ce10f1bf..f56745c0c1 100644 --- a/drivers/SmartThings/zigbee-button/src/thirdreality/init.lua +++ b/drivers/SmartThings/zigbee-button/src/thirdreality/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local MULTISTATE_INPUT_ATTR = 0x0012 @@ -41,9 +44,7 @@ local thirdreality_device_handler = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Third Reality, Inc" and device:get_model() == "3RSB22BZ" - end + can_handle = require("thirdreality.can_handle"), } return thirdreality_device_handler diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/SLED/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/SLED/can_handle.lua new file mode 100644 index 0000000000..4e96689e08 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/SLED/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function SLED_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Samsung Electronics" and device:get_model() == "SAMSUNG-ITM-Z-005" then + return true, require("zigbee-multi-button.SLED") + end + return false +end + +return SLED_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/SLED/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/SLED/init.lua index ea6a9d5faa..cab7458507 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/SLED/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/SLED/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -72,9 +62,7 @@ local SLED_button = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Samsung Electronics" and device:get_model() == "SAMSUNG-ITM-Z-005" - end + can_handle = require("zigbee-multi-button.SLED.can_handle"), } return SLED_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/can_handle.lua new file mode 100644 index 0000000000..9b4eedd1ff --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aduro_button = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-multi-button.adurosmart.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button.adurosmart") + end + end + return false +end + +return is_aduro_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/fingerprints.lua new file mode 100644 index 0000000000..c5870be270 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ADURO_BUTTON_FINGERPRINTS = { + { mfr = "AduroSmart Eria", model = "ADUROLIGHT_CSC" }, + { mfr = "ADUROLIGHT", model = "ADUROLIGHT_CSC" }, + { mfr = "AduroSmart Eria", model = "Adurolight_NCC" }, + { mfr = "ADUROLIGHT", model = "Adurolight_NCC" } +} + +return ADURO_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/init.lua index 76a6ba6aae..56a29a1d0a 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/adurosmart/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -25,21 +15,7 @@ local ADURO_NUM_ENDPOINT = 0x04 local ADURO_MANUFACTURER_SPECIFIC_CLUSTER = 0xFCCC local ADURO_MANUFACTURER_SPECIFIC_CMD = 0x00 -local ADURO_BUTTON_FINGERPRINTS = { - { mfr = "AduroSmart Eria", model = "ADUROLIGHT_CSC" }, - { mfr = "ADUROLIGHT", model = "ADUROLIGHT_CSC" }, - { mfr = "AduroSmart Eria", model = "Adurolight_NCC" }, - { mfr = "ADUROLIGHT", model = "Adurolight_NCC" } -} -local is_aduro_button = function(opts, driver, device) - for _, fingerprint in ipairs(ADURO_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local do_configuration = function(self, device) for endpoint = 1,ADURO_NUM_ENDPOINT do @@ -83,7 +59,7 @@ local aduro_device_handler = { } } }, - can_handle = is_aduro_button + can_handle = require("zigbee-multi-button.adurosmart.can_handle"), } return aduro_device_handler diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/can_handle.lua new file mode 100644 index 0000000000..bd1c57940f --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zigbee_multi_button(opts, driver, device, ...) + local FINGERPRINTS = require("zigbee-multi-button.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button") + end + end + return false +end + +return can_handle_zigbee_multi_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/can_handle.lua new file mode 100644 index 0000000000..0216d3729c --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_centralite_button = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-multi-button.centralite.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button.centralite") + end + end + return false +end + +return is_centralite_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/fingerprints.lua new file mode 100644 index 0000000000..d742cb6c64 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CENTRALITE_BUTTON_FINGERPRINTS = { + { mfr = "CentraLite", model = "3450-L" }, + { mfr = "CentraLite", model = "3450-L2" } +} + +return CENTRALITE_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/init.lua index 8eb3762be4..d291efb0cb 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/centralite/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local device_management = require "st.zigbee.device_management" @@ -30,19 +20,7 @@ local EP_BUTTON_COMPONENT_MAP = { [0x04] = 2 } -local CENTRALITE_BUTTON_FINGERPRINTS = { - { mfr = "CentraLite", model = "3450-L" }, - { mfr = "CentraLite", model = "3450-L2" } -} -local is_centralite_button = function(opts, driver, device) - for _, fingerprint in ipairs(CENTRALITE_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local do_configuration = function(self, device) device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) @@ -75,7 +53,7 @@ local centralite_device_handler = { } } }, - can_handle = is_centralite_button + can_handle = require("zigbee-multi-button.centralite.can_handle"), } return centralite_device_handler diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ecosmart/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ecosmart/can_handle.lua new file mode 100644 index 0000000000..217df6b26f --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ecosmart/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function ecosmart_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "LDS" and device:get_model() == "ZBT-CCTSwitch-D0001" then + return true, require("zigbee-multi-button.ecosmart") + end + return false +end + +return ecosmart_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ecosmart/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ecosmart/init.lua index 8f7d4ec6d8..215adba69c 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ecosmart/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ecosmart/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -126,9 +116,7 @@ local ecosmart_button = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "LDS" and device:get_model() == "ZBT-CCTSwitch-D0001" - end + can_handle = require("zigbee-multi-button.ecosmart.can_handle"), } return ecosmart_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/fingerprints.lua new file mode 100644 index 0000000000..05ef834306 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/fingerprints.lua @@ -0,0 +1,43 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_MULTI_BUTTON_FINGERPRINTS = { + { mfr = "CentraLite", model = "3450-L" }, + { mfr = "CentraLite", model = "3450-L2" }, + { mfr = "AduroSmart Eria", model = "ADUROLIGHT_CSC" }, + { mfr = "ADUROLIGHT", model = "ADUROLIGHT_CSC" }, + { mfr = "AduroSmart Eria", model = "Adurolight_NCC" }, + { mfr = "ADUROLIGHT", model = "Adurolight_NCC" }, + { mfr = "HEIMAN", model = "SceneSwitch-EM-3.0" }, + { mfr = "HEIMAN", model = "HS6SSA-W-EF-3.0" }, + { mfr = "HEIMAN", model = "HS6SSB-W-EF-3.0" }, + { mfr = "IKEA of Sweden", model = "TRADFRI on/off switch" }, + { mfr = "IKEA of Sweden", model = "TRADFRI open/close remote" }, + { mfr = "IKEA of Sweden", model = "TRADFRI remote control" }, + { mfr = "KE", model = "TRADFRI open/close remote" }, + { mfr = "\x02KE", model = "TRADFRI open/close remote" }, + { mfr = "SOMFY", model = "Situo 1 Zigbee" }, + { mfr = "SOMFY", model = "Situo 4 Zigbee" }, + { mfr = "LDS", model = "ZBT-CCTSwitch-D0001" }, + { mfr = "ShinaSystem", model = "MSM-300Z" }, + { mfr = "ShinaSystem", model = "BSM-300Z" }, + { mfr = "ShinaSystem", model = "SBM300ZB1" }, + { mfr = "ShinaSystem", model = "SBM300ZB2" }, + { mfr = "ShinaSystem", model = "SBM300ZB3" }, + { mfr = "ShinaSystem", model = "SBM300ZC1" }, + { mfr = "ShinaSystem", model = "SBM300ZC2" }, + { mfr = "ShinaSystem", model = "SBM300ZC3" }, + { mfr = "ShinaSystem", model = "SBM300ZC4" }, + { mfr = "ShinaSystem", model = "SQM300ZC4" }, + { mfr = "ROBB smarrt", model = "ROB_200-007-0" }, + { mfr = "ROBB smarrt", model = "ROB_200-008-0" }, + { mfr = "WALL HERO", model = "ACL-401SCA4" }, + { mfr = "Samsung Electronics", model = "SAMSUNG-ITM-Z-005" }, + { mfr = "Vimar", model = "RemoteControl_v1.0" }, + { mfr = "Linxura", model = "Smart Controller" }, + { mfr = "Linxura", model = "Aura Smart Button" }, + { mfr = "SONOFF", model = "SNZB-01M" }, + { mfr = "zunzunbee", model = "SSWZ8T" } +} + +return ZIGBEE_MULTI_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/can_handle.lua new file mode 100644 index 0000000000..09e0575266 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_heiman_button = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-multi-button.heiman.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button.heiman") + end + end + return false +end + +return is_heiman_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/fingerprints.lua new file mode 100644 index 0000000000..68723ba700 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local HEIMAN_BUTTON_FINGERPRINTS = { + { mfr = "HEIMAN", model = "SceneSwitch-EM-3.0", endpoint_num = 0x04 }, + { mfr = "HEIMAN", model = "HS6SSA-W-EF-3.0", endpoint_num = 0x04 }, + { mfr = "HEIMAN", model = "HS6SSB-W-EF-3.0", endpoint_num = 0x03 }, +} + +return HEIMAN_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/init.lua index 3c32e33fbe..980472671f 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/heiman/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -22,25 +12,12 @@ local OnOff = clusters.OnOff local PowerConfiguration = clusters.PowerConfiguration local Scenes = clusters.Scenes -local HEIMAN_GROUP_CONFIGURE = "is_group_configured" - -local HEIMAN_BUTTON_FINGERPRINTS = { - { mfr = "HEIMAN", model = "SceneSwitch-EM-3.0", endpoint_num = 0x04 }, - { mfr = "HEIMAN", model = "HS6SSA-W-EF-3.0", endpoint_num = 0x04 }, - { mfr = "HEIMAN", model = "HS6SSB-W-EF-3.0", endpoint_num = 0x03 }, -} +local FINGERPRINTS = require("zigbee-multi-button.heiman.fingerprints") -local is_heiman_button = function(opts, driver, device) - for _, fingerprint in ipairs(HEIMAN_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end +local HEIMAN_GROUP_CONFIGURE = "is_group_configured" local function get_endpoint_num(device) - for _, fingerprint in ipairs(HEIMAN_BUTTON_FINGERPRINTS) do + for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_model() == fingerprint.model then return fingerprint.endpoint_num end @@ -123,7 +100,7 @@ local heiman_device_handler = { } } }, - can_handle = is_heiman_button + can_handle = require("zigbee-multi-button.heiman.can_handle"), } return heiman_device_handler diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch/can_handle.lua new file mode 100644 index 0000000000..1c9621e53b --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch/can_handle.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + if device:get_model() == "TRADFRI on/off switch" then + return true, require("zigbee-multi-button.ikea.TRADFRI_on_off_switch") + end + return false +end diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch/init.lua similarity index 55% rename from drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch.lua rename to drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch/init.lua index c6862ef533..87dcfcd47a 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_on_off_switch/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -33,9 +22,7 @@ local on_off_switch = { }, } }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "TRADFRI on/off switch" - end + can_handle = require "zigbee-multi-button.ikea.TRADFRI_on_off_switch.can_handle" } return on_off_switch diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote.lua deleted file mode 100644 index bed6086e43..0000000000 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote.lua +++ /dev/null @@ -1,36 +0,0 @@ --- Copyright 2022 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. - -local capabilities = require "st.capabilities" -local clusters = require "st.zigbee.zcl.clusters" -local button_utils = require "button_utils" - -local WindowCovering = clusters.WindowCovering - -local open_close_remote = { - NAME = "Open/Close Remote", - zigbee_handlers = { - cluster = { - [WindowCovering.ID] = { - [WindowCovering.server.commands.UpOrOpen.ID] = button_utils.build_button_handler("button1", capabilities.button.button.pushed), - [WindowCovering.server.commands.DownOrClose.ID] = button_utils.build_button_handler("button2", capabilities.button.button.pushed) - } - } - }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "TRADFRI open/close remote" - end -} - -return open_close_remote diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote/can_handle.lua new file mode 100644 index 0000000000..aed0256524 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote/can_handle.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + if device:get_model() == "TRADFRI open/close remote" then + return true, require("zigbee-multi-button.ikea.TRADFRI_open_close_remote") + end + return false +end diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote/init.lua new file mode 100644 index 0000000000..23a622eb43 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_open_close_remote/init.lua @@ -0,0 +1,23 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local button_utils = require "button_utils" + +local WindowCovering = clusters.WindowCovering + +local open_close_remote = { + NAME = "Open/Close Remote", + zigbee_handlers = { + cluster = { + [WindowCovering.ID] = { + [WindowCovering.server.commands.UpOrOpen.ID] = button_utils.build_button_handler("button1", capabilities.button.button.pushed), + [WindowCovering.server.commands.DownOrClose.ID] = button_utils.build_button_handler("button2", capabilities.button.button.pushed) + } + } + }, + can_handle = require "zigbee-multi-button.ikea.TRADFRI_open_close_remote.can_handle", +} + +return open_close_remote diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control/can_handle.lua new file mode 100644 index 0000000000..d45924cfd1 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control/can_handle.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + if device:get_model() == "TRADFRI remote control" then + return true, require("zigbee-multi-button.ikea.TRADFRI_remote_control") + end + return false +end diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control/init.lua similarity index 81% rename from drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control.lua rename to drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control/init.lua index 0d9f12a697..bc608749b0 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/TRADFRI_remote_control/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -84,9 +73,7 @@ local remote_control = { lifecycle_handlers = { added = added_handler }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "TRADFRI remote control" - end + can_handle = require "zigbee-multi-button.ikea.TRADFRI_remote_control.can_handle" } diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/can_handle.lua new file mode 100644 index 0000000000..9e1b4676f2 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local can_handle_ikea = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-multi-button.ikea.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr then + return true, require("zigbee-multi-button.ikea") + end + end + return false +end + +return can_handle_ikea diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/fingerprints.lua new file mode 100644 index 0000000000..f15d195eef --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IKEA_MFG = { + { mfr = "IKEA of Sweden" }, + { mfr = "KE" }, + { mfr = "\02KE" } +} + +return IKEA_MFG diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/init.lua index 9a66a85991..97de983eff 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local constants = require "st.zigbee.constants" @@ -30,20 +20,7 @@ local Groups = clusters.Groups local ENTRIES_READ = "ENTRIES_READ" -local IKEA_MFG = { - { mfr = "IKEA of Sweden" }, - { mfr = "KE" }, - { mfr = "\02KE" } -} -local can_handle_ikea = function(opts, driver, device) - for _, fingerprint in ipairs(IKEA_MFG) do - if device:get_manufacturer() == fingerprint.mfr then - return true - end - end - return false -end local do_configure = function(self, device) device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) @@ -140,12 +117,8 @@ local ikea_of_sweden = { } } }, - sub_drivers = { - require("zigbee-multi-button.ikea.TRADFRI_remote_control"), - require("zigbee-multi-button.ikea.TRADFRI_on_off_switch"), - require("zigbee-multi-button.ikea.TRADFRI_open_close_remote") - }, - can_handle = can_handle_ikea + sub_drivers = require("zigbee-multi-button.ikea.sub_drivers"), + can_handle = require("zigbee-multi-button.ikea.can_handle"), } return ikea_of_sweden diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/sub_drivers.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/sub_drivers.lua new file mode 100644 index 0000000000..6d7d567775 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/ikea/sub_drivers.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_sub_driver = require "lazy_load_subdriver" + +local sub_drivers = { + lazy_load_sub_driver("zigbee-multi-button.ikea.TRADFRI_remote_control"), + lazy_load_sub_driver("zigbee-multi-button.ikea.TRADFRI_on_off_switch"), + lazy_load_sub_driver("zigbee-multi-button.ikea.TRADFRI_open_close_remote") +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/init.lua index 539b03785b..daaa24a8b6 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/init.lua @@ -1,67 +1,12 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" local supported_values = require "zigbee-multi-button.supported_values" local button_utils = require "button_utils" -local ZIGBEE_MULTI_BUTTON_FINGERPRINTS = { - { mfr = "CentraLite", model = "3450-L" }, - { mfr = "CentraLite", model = "3450-L2" }, - { mfr = "AduroSmart Eria", model = "ADUROLIGHT_CSC" }, - { mfr = "ADUROLIGHT", model = "ADUROLIGHT_CSC" }, - { mfr = "AduroSmart Eria", model = "Adurolight_NCC" }, - { mfr = "ADUROLIGHT", model = "Adurolight_NCC" }, - { mfr = "HEIMAN", model = "SceneSwitch-EM-3.0" }, - { mfr = "HEIMAN", model = "HS6SSA-W-EF-3.0" }, - { mfr = "HEIMAN", model = "HS6SSB-W-EF-3.0" }, - { mfr = "IKEA of Sweden", model = "TRADFRI on/off switch" }, - { mfr = "IKEA of Sweden", model = "TRADFRI open/close remote" }, - { mfr = "IKEA of Sweden", model = "TRADFRI remote control" }, - { mfr = "KE", model = "TRADFRI open/close remote" }, - { mfr = "\x02KE", model = "TRADFRI open/close remote" }, - { mfr = "SOMFY", model = "Situo 1 Zigbee" }, - { mfr = "SOMFY", model = "Situo 4 Zigbee" }, - { mfr = "LDS", model = "ZBT-CCTSwitch-D0001" }, - { mfr = "ShinaSystem", model = "MSM-300Z" }, - { mfr = "ShinaSystem", model = "BSM-300Z" }, - { mfr = "ShinaSystem", model = "SBM300ZB1" }, - { mfr = "ShinaSystem", model = "SBM300ZB2" }, - { mfr = "ShinaSystem", model = "SBM300ZB3" }, - { mfr = "ShinaSystem", model = "SBM300ZC1" }, - { mfr = "ShinaSystem", model = "SBM300ZC2" }, - { mfr = "ShinaSystem", model = "SBM300ZC3" }, - { mfr = "ShinaSystem", model = "SBM300ZC4" }, - { mfr = "ShinaSystem", model = "SQM300ZC4" }, - { mfr = "ROBB smarrt", model = "ROB_200-007-0" }, - { mfr = "ROBB smarrt", model = "ROB_200-008-0" }, - { mfr = "WALL HERO", model = "ACL-401SCA4" }, - { mfr = "Samsung Electronics", model = "SAMSUNG-ITM-Z-005" }, - { mfr = "Vimar", model = "RemoteControl_v1.0" }, - { mfr = "Linxura", model = "Smart Controller" }, - { mfr = "Linxura", model = "Aura Smart Button" }, - { mfr = "zunzunbee", model = "SSWZ8T" } -} - -local function can_handle_zigbee_multi_button(opts, driver, device, ...) - for _, fingerprint in ipairs(ZIGBEE_MULTI_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function added_handler(self, device) local config = supported_values.get_device_parameters(device) @@ -87,22 +32,8 @@ local zigbee_multi_button = { lifecycle_handlers = { added = added_handler }, - can_handle = can_handle_zigbee_multi_button, - sub_drivers = { - require("zigbee-multi-button.ikea"), - require("zigbee-multi-button.somfy"), - require("zigbee-multi-button.ecosmart"), - require("zigbee-multi-button.centralite"), - require("zigbee-multi-button.adurosmart"), - require("zigbee-multi-button.heiman"), - require("zigbee-multi-button.shinasystems"), - require("zigbee-multi-button.robb"), - require("zigbee-multi-button.wallhero"), - require("zigbee-multi-button.SLED"), - require("zigbee-multi-button.vimar"), - require("zigbee-multi-button.linxura"), - require("zigbee-multi-button.zunzunbee") - } + can_handle = require("zigbee-multi-button.can_handle"), + sub_drivers = require("zigbee-multi-button.sub_drivers"), } return zigbee_multi_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/can_handle.lua new file mode 100644 index 0000000000..5d8aa4bd2e --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_linxura_button = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-multi-button.linxura.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button.linxura") + end + end + return false +end + +return is_linxura_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/fingerprints.lua new file mode 100644 index 0000000000..0320a5f2dd --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local LINXURA_BUTTON_FINGERPRINTS = { + { mfr = "Linxura", model = "Smart Controller"}, + { mfr = "Linxura", model = "Aura Smart Button"} +} + +return LINXURA_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/init.lua index 0e7cf44e93..cb1dd362a2 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/linxura/init.lua @@ -1,25 +1,11 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local IASZone = (require "st.zigbee.zcl.clusters").IASZone local log = require "log" -local LINXURA_BUTTON_FINGERPRINTS = { - { mfr = "Linxura", model = "Smart Controller"}, - { mfr = "Linxura", model = "Aura Smart Button"} -} local configuration = { { @@ -31,14 +17,6 @@ local configuration = { reportable_change = 1 } } -local is_linxura_button = function(opts, driver, device) - for _, fingerprint in ipairs(LINXURA_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function present_value_attr_handler(driver, device, zone_status, zb_rx) log.info("present_value_attr_handler The current value is: ", zone_status.value) @@ -84,7 +62,7 @@ local linxura_device_handler = { } }, - can_handle = is_linxura_button + can_handle = require("zigbee-multi-button.linxura.can_handle"), } return linxura_device_handler diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/can_handle.lua new file mode 100644 index 0000000000..7b30164eaa --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device, ...) + local ROBB_MFR_STRING = "ROBB smarrt" + local WIRELESS_REMOTE_FINGERPRINTS = require "zigbee-multi-button.robb.fingerprints" + + if device:get_manufacturer() == ROBB_MFR_STRING and WIRELESS_REMOTE_FINGERPRINTS[device:get_model()] then + return true, require("zigbee-multi-button.robb") + else + return false + end +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/fingerprints.lua new file mode 100644 index 0000000000..0b9e1b3f51 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/fingerprints.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local WIRELESS_REMOTE_FINGERPRINTS = { + ["ROB_200-008-0"] = { + endpoints = 2, + buttons = 4 + }, + ["ROB_200-007-0"] = { + endpoints = 4, + buttons = 8 + } +} + +return WIRELESS_REMOTE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/init.lua index dddef6f595..4aca7d95aa 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/robb/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -18,7 +21,6 @@ Each button-row represents one endpoint. The 8x remote control has four endpoint That means each endpoint has two buttons. --]] -local ROBB_MFR_STRING = "ROBB smarrt" local WIRELESS_REMOTE_FINGERPRINTS = { ["ROB_200-008-0"] = { endpoints = 2, @@ -30,13 +32,6 @@ local WIRELESS_REMOTE_FINGERPRINTS = { } } -local function can_handle(opts, driver, device, ...) - if device:get_manufacturer() == ROBB_MFR_STRING and WIRELESS_REMOTE_FINGERPRINTS[device:get_model()] then - return true - else - return false - end -end local button_push_handler = function(addF) return function(driver, device, zb_rx) @@ -186,7 +181,7 @@ local robb_wireless_control = { } } }, - can_handle = can_handle + can_handle = require("zigbee-multi-button.robb.can_handle"), } return robb_wireless_control diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/can_handle.lua new file mode 100644 index 0000000000..d62ba886ad --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_shinasystem_button = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-multi-button.shinasystems.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button.shinasystems") + end + end + return false +end + +return is_shinasystem_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/fingerprints.lua new file mode 100644 index 0000000000..5d0d1abb6f --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/fingerprints.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SHINASYSTEM_BUTTON_FINGERPRINTS = { + { mfr = "ShinaSystem", model = "MSM-300Z", endpoint_num = 0x04 }, + { mfr = "ShinaSystem", model = "BSM-300Z", endpoint_num = 0x01 }, + { mfr = "ShinaSystem", model = "SBM300ZB1", endpoint_num = 0x01 }, + { mfr = "ShinaSystem", model = "SBM300ZB2", endpoint_num = 0x02 }, + { mfr = "ShinaSystem", model = "SBM300ZB3", endpoint_num = 0x03 }, + { mfr = "ShinaSystem", model = "SBM300ZC1", endpoint_num = 0x01 }, + { mfr = "ShinaSystem", model = "SBM300ZC2", endpoint_num = 0x02 }, + { mfr = "ShinaSystem", model = "SBM300ZC3", endpoint_num = 0x03 }, + { mfr = "ShinaSystem", model = "SBM300ZC4", endpoint_num = 0x04 }, + { mfr = "ShinaSystem", model = "SQM300ZC4", endpoint_num = 0x04 } +} + +return SHINASYSTEM_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/init.lua index 33528b3641..5d5b920db9 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/shinasystems/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -20,30 +10,11 @@ local OnOff = clusters.OnOff local device_management = require "st.zigbee.device_management" local Groups = clusters.Groups -local SHINASYSTEM_BUTTON_FINGERPRINTS = { - { mfr = "ShinaSystem", model = "MSM-300Z", endpoint_num = 0x04 }, - { mfr = "ShinaSystem", model = "BSM-300Z", endpoint_num = 0x01 }, - { mfr = "ShinaSystem", model = "SBM300ZB1", endpoint_num = 0x01 }, - { mfr = "ShinaSystem", model = "SBM300ZB2", endpoint_num = 0x02 }, - { mfr = "ShinaSystem", model = "SBM300ZB3", endpoint_num = 0x03 }, - { mfr = "ShinaSystem", model = "SBM300ZC1", endpoint_num = 0x01 }, - { mfr = "ShinaSystem", model = "SBM300ZC2", endpoint_num = 0x02 }, - { mfr = "ShinaSystem", model = "SBM300ZC3", endpoint_num = 0x03 }, - { mfr = "ShinaSystem", model = "SBM300ZC4", endpoint_num = 0x04 }, - { mfr = "ShinaSystem", model = "SQM300ZC4", endpoint_num = 0x04 } -} -local is_shinasystem_button = function(opts, driver, device) - for _, fingerprint in ipairs(SHINASYSTEM_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function get_ep_num_shinasystem_button(device) - for _, fingerprint in ipairs(SHINASYSTEM_BUTTON_FINGERPRINTS) do + local FINGERPRINTS = require("zigbee-multi-button.shinasystems.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_model() == fingerprint.model then return fingerprint.endpoint_num end @@ -92,7 +63,7 @@ local shinasystem_device_handler = { } } }, - can_handle = is_shinasystem_button + can_handle = require("zigbee-multi-button.shinasystems.can_handle"), } return shinasystem_device_handler diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/can_handle.lua new file mode 100644 index 0000000000..55e2919fef --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function somfy_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "SOMFY" then + return true, require("zigbee-multi-button.somfy") + end + return false +end + +return somfy_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/init.lua index 7236d22491..f4b7951a8f 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local constants = require "st.zigbee.constants" @@ -65,13 +55,8 @@ local somfy = { [mgmt_bind_resp.MGMT_BIND_RESPONSE] = zdo_binding_table_handler } }, - sub_drivers = { - require("zigbee-multi-button.somfy.somfy_situo_1"), - require("zigbee-multi-button.somfy.somfy_situo_4") - }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "SOMFY" - end + sub_drivers = require("zigbee-multi-button.somfy.sub_drivers"), + can_handle = require("zigbee-multi-button.somfy.can_handle"), } return somfy diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1/can_handle.lua new file mode 100644 index 0000000000..45584d2f13 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1/can_handle.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + if device:get_model() == "Situo 1 Zigbee" then + return true, require("zigbee-multi-button.somfy.somfy_situo_1") + end + return false +end diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1/init.lua similarity index 78% rename from drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1.lua rename to drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1/init.lua index de20408306..b81791038e 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_1/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local constants = require "st.zigbee.constants" @@ -62,9 +51,7 @@ local somfy_handler = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "Situo 1 Zigbee" - end + can_handle = require("zigbee-multi-button.somfy.somfy_situo_1.can_handle"), } return somfy_handler diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4/can_handle.lua new file mode 100644 index 0000000000..15b70ad23e --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4/can_handle.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + if device:get_model() == "Situo 4 Zigbee" then + return true, require("zigbee-multi-button.somfy.somfy_situo_4") + end + return false +end diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4/init.lua similarity index 83% rename from drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4.lua rename to drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4/init.lua index cd9c59bf90..ca7892d578 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/somfy_situo_4/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local constants = require "st.zigbee.constants" @@ -103,9 +92,7 @@ local somfy_situo_4_handler = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "Situo 4 Zigbee" - end + can_handle = require("zigbee-multi-button.somfy.somfy_situo_4.can_handle"), } return somfy_situo_4_handler diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/sub_drivers.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/sub_drivers.lua new file mode 100644 index 0000000000..c1c88d1e7c --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/somfy/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_subdriver = require "lazy_load_subdriver" + +local sub_drivers = { + lazy_load_subdriver("zigbee-multi-button.somfy.somfy_situo_1"), + lazy_load_subdriver("zigbee-multi-button.somfy.somfy_situo_4"), +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/can_handle.lua new file mode 100644 index 0000000000..a1f689f4de --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function sonoff_can_handle(opts, driver, device, ...) + local fingerprints = require("zigbee-multi-button.sonoff.fingerprints") + + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button.sonoff") + end + end + + return false +end + +return sonoff_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/fingerprints.lua new file mode 100644 index 0000000000..efba976feb --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SONOFF_FINGERPRINTS = { + { mfr = "SONOFF", model = "SNZB-01M" } +} + +return SONOFF_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/init.lua new file mode 100644 index 0000000000..41390b4d44 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sonoff/init.lua @@ -0,0 +1,63 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local button_utils = require "button_utils" + +local SONOFF_CLUSTER_ID = 0xFC12 +local SONOFF_ATTR_ID = 0x0000 +local SONOFF_SUPPORTED_BUTTON_VALUES = { "pushed", "double", "held", "pushed_3x" } +local SONOFF_NUMBER_OF_BUTTONS = 4 + +local EVENT_MAP = { + [0x01] = capabilities.button.button.pushed, + [0x02] = capabilities.button.button.double, + [0x03] = capabilities.button.button.held, + [0x04] = capabilities.button.button.pushed_3x +} + +local function added_handler(self, device) + for _, component in pairs(device.profile.components) do + local number_of_buttons = component.id == "main" and SONOFF_NUMBER_OF_BUTTONS or 1 + device:emit_component_event(component, + capabilities.button.supportedButtonValues(SONOFF_SUPPORTED_BUTTON_VALUES, { visibility = { displayed = false } })) + device:emit_component_event(component, + capabilities.button.numberOfButtons({ value = number_of_buttons }, { visibility = { displayed = false } })) + end + + button_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, + capabilities.button.button.NAME, capabilities.button.button.pushed({ state_change = false })) +end + +local function sonoff_attr_handler(driver, device, value, zb_rx) + local attr_val = value.value + local endpoint = zb_rx.address_header.src_endpoint.value + local button_name = "button" .. tostring(endpoint) + local event_func = EVENT_MAP[attr_val] + if event_func then + local comp = device.profile.components[button_name] + if comp then + local event = event_func({ state_change = true }) + device:emit_component_event(comp, event) + device:emit_event(event) + end + end +end + +local sonoff_handler = { + NAME = "SONOFF Multi-Button Handler", + lifecycle_handlers = { + added = added_handler + }, + zigbee_handlers = { + attr = { + [SONOFF_CLUSTER_ID] = { + [SONOFF_ATTR_ID] = sonoff_attr_handler + } + } + }, + can_handle = require("zigbee-multi-button.sonoff.can_handle") +} + +return sonoff_handler + diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sub_drivers.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sub_drivers.lua new file mode 100644 index 0000000000..bd7828c466 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/sub_drivers.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zigbee-multi-button.ikea"), + lazy_load_if_possible("zigbee-multi-button.somfy"), + lazy_load_if_possible("zigbee-multi-button.ecosmart"), + lazy_load_if_possible("zigbee-multi-button.sonoff"), + lazy_load_if_possible("zigbee-multi-button.centralite"), + lazy_load_if_possible("zigbee-multi-button.adurosmart"), + lazy_load_if_possible("zigbee-multi-button.heiman"), + lazy_load_if_possible("zigbee-multi-button.shinasystems"), + lazy_load_if_possible("zigbee-multi-button.robb"), + lazy_load_if_possible("zigbee-multi-button.wallhero"), + lazy_load_if_possible("zigbee-multi-button.SLED"), + lazy_load_if_possible("zigbee-multi-button.vimar"), + lazy_load_if_possible("zigbee-multi-button.linxura"), + lazy_load_if_possible("zigbee-multi-button.zunzunbee"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/supported_values.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/supported_values.lua index 172a7c1ca3..813859f891 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/supported_values.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/supported_values.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local devices = { BUTTON_PUSH_HELD_2 = { diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/can_handle.lua new file mode 100644 index 0000000000..8317f95893 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function vimar_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Vimar" and device:get_model() == "RemoteControl_v1.0" then + return true, require("zigbee-multi-button.vimar") + end + return false +end + +return vimar_can_handle diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/init.lua index 3df8cc4d71..aa7ba729a1 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/vimar/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -85,9 +75,7 @@ local vimar_remote_control = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Vimar" and device:get_model() == "RemoteControl_v1.0" - end + can_handle = require("zigbee-multi-button.vimar.can_handle"), } return vimar_remote_control diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/can_handle.lua new file mode 100644 index 0000000000..633871874d --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_wallhero_button(opts, driver, device, ...) + local FINGERPRINTS = require("zigbee-multi-button.wallhero.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button.wallhero") + end + end + return false +end + +return can_handle_wallhero_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/fingerprints.lua new file mode 100644 index 0000000000..6b6ca8b00f --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "WALL HERO", model = "ACL-401SCA4" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/init.lua index 71b01c84f8..e562aef77d 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/wallhero/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local log = require "log" @@ -19,18 +9,7 @@ local zcl_clusters = require "st.zigbee.zcl.clusters" local Scenes = zcl_clusters.Scenes -local FINGERPRINTS = { - { mfr = "WALL HERO", model = "ACL-401SCA4" } -} -local function can_handle_wallhero_button(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function scenes_cluster_handler(driver, device, zb_rx) local additional_fields = { @@ -97,7 +76,7 @@ local wallhero_button = { } } }, - can_handle = can_handle_wallhero_button + can_handle = require("zigbee-multi-button.wallhero.can_handle"), } return wallhero_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/can_handle.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/can_handle.lua new file mode 100644 index 0000000000..0277725380 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Check if a given device matches the supported fingerprints +local function is_zunzunbee_button(opts, driver, device) + local ZUNZUNBEE_BUTTON_FINGERPRINTS = require "zigbee-multi-button.zunzunbee.fingerprints" + for _, fingerprint in ipairs(ZUNZUNBEE_BUTTON_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-multi-button.zunzunbee") + end + end + return false +end + +return is_zunzunbee_button diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/fingerprints.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/fingerprints.lua new file mode 100644 index 0000000000..b893f4c1e6 --- /dev/null +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- List of supported device fingerprints +local ZUNZUNBEE_BUTTON_FINGERPRINTS = { + { mfr = "zunzunbee", model = "SSWZ8T" } +} + +return ZUNZUNBEE_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/init.lua b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/init.lua index 7bb209a622..e8fa24a63e 100644 --- a/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/init.lua +++ b/drivers/SmartThings/zigbee-button/src/zigbee-multi-button/zunzunbee/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local Tone = capabilities.tone @@ -30,26 +20,11 @@ local battery_config = utils.deep_copy(battery_defaults.default_percentage_confi battery_config.reportable_change = 0x02 battery_config.data_type = clusters.PowerConfiguration.attributes.BatteryVoltage.base_type --- List of supported device fingerprints -local ZUNZUNBEE_BUTTON_FINGERPRINTS = { - { mfr = "zunzunbee", model = "SSWZ8T" } -} - -- Initialize device attributes local function init_handler(self, device) device:add_configured_attribute(battery_config) end --- Check if a given device matches the supported fingerprints -local function is_zunzunbee_button(opts, driver, device) - for _, fingerprint in ipairs(ZUNZUNBEE_BUTTON_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - -- Generate and emit button events based on IAS Zone status attribute local function generate_button_event_from_zone_status(driver, device, zone_status, zb_rx) local raw_value = tonumber(zone_status.value) @@ -163,7 +138,7 @@ local zunzunbee_device_handler = { } } }, - can_handle = is_zunzunbee_button + can_handle = require("zigbee-multi-button.zunzunbee.can_handle"), } return zunzunbee_device_handler diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/fingerprints.yml b/drivers/SmartThings/zigbee-carbon-monoxide-detector/fingerprints.yml index 3c05e02436..eb0d7e0209 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/fingerprints.yml +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/fingerprints.yml @@ -14,3 +14,13 @@ zigbeeManufacturer: manufacturer: HEIMAN model: COSensor-EM deviceProfileName: carbonMonoxide-battery + - id: "frient A/S/SCAZB-143" + deviceLabel: Frient Carbon Monoxide Detector + manufacturer: frient A/S + model: SCAZB-143 + deviceProfileName: frient-smoke-co-temperature-battery + - id: "frient A/S/SCAZB-141" + deviceLabel: Frient Carbon Monoxide Detector + manufacturer: frient A/S + model: SCAZB-141 + deviceProfileName: frient-smoke-co-temperature-battery \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/profiles/frient-smoke-co-temperature-battery.yml b/drivers/SmartThings/zigbee-carbon-monoxide-detector/profiles/frient-smoke-co-temperature-battery.yml new file mode 100644 index 0000000000..3a22a0ad77 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/profiles/frient-smoke-co-temperature-battery.yml @@ -0,0 +1,55 @@ +name: frient-smoke-co-temperature-battery +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: carbonMonoxideDetector + version: 1 + - id: carbonMonoxideMeasurement + version: 1 + - id: tamperAlert + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + - id: alarm + version: 1 + config: + values: + - key: "alarm.value" + enabledValues: + - off + - siren + - key: "{{enumCommands}}" + enabledValues: + - off + - siren + categories: + - name: SmokeDetector +preferences: +- title: "Max alarm duration (s)" + name: maxWarningDuration + description: "After how many seconds should the alarm turn off" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 +- preferenceId: tempOffset + explicit: true +- title: "Temperature Sensitivity (°C)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/can_handle.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/can_handle.lua new file mode 100644 index 0000000000..95ad1a88fa --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_climax_technology_carbon_monoxide = function(opts, driver, device) + local FINGERPRINTS = require("ClimaxTechnology.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("ClimaxTechnology") + end + end + + return false +end + +return is_climax_technology_carbon_monoxide diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/fingerprints.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/fingerprints.lua new file mode 100644 index 0000000000..d72389f772 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CLIMAX_TECHNOLOGY_CARBON_MONOXIDE_FINGERPRINTS = { + { mfr = "ClimaxTechnology", model = "CO_00.00.00.22TC" }, + { mfr = "ClimaxTechnology", model = "CO_00.00.00.15TC" } +} + +return CLIMAX_TECHNOLOGY_CARBON_MONOXIDE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/init.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/init.lua index cf96fb7162..1f18983c4b 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/init.lua +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/ClimaxTechnology/init.lua @@ -1,33 +1,10 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" -local CLIMAX_TECHNOLOGY_CARBON_MONOXIDE_FINGERPRINTS = { - { mfr = "ClimaxTechnology", model = "CO_00.00.00.22TC" }, - { mfr = "ClimaxTechnology", model = "CO_00.00.00.15TC" } -} +local capabilities = require "st.capabilities" -local is_climax_technology_carbon_monoxide = function(opts, driver, device) - for _, fingerprint in ipairs(CLIMAX_TECHNOLOGY_CARBON_MONOXIDE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local device_added = function(self, device) device:emit_event(capabilities.battery.battery(100)) @@ -38,7 +15,7 @@ local climax_technology_carbon_monoxide = { lifecycle_handlers = { added = device_added }, - can_handle = is_climax_technology_carbon_monoxide + can_handle = require("ClimaxTechnology.can_handle"), } return climax_technology_carbon_monoxide diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/can_handle.lua new file mode 100644 index 0000000000..f451011e03 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_frient_smoke_carbon_monoxide = function(opts, driver, device) + local FINGERPRINTS = require("frient.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("frient") + end + end + + return false +end + +return is_frient_smoke_carbon_monoxide diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/fingerprints.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/fingerprints.lua new file mode 100644 index 0000000000..22180458e9 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FRIENT_SMOKE_CARBON_MONOXIDE_FINGERPRINTS = { + { mfr = "frient A/S", model = "SCAZB-141" }, + { mfr = "frient A/S", model = "SCAZB-143" } +} + +return FRIENT_SMOKE_CARBON_MONOXIDE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/init.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/init.lua new file mode 100644 index 0000000000..4f7d8fa8d5 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/frient/init.lua @@ -0,0 +1,236 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local SinglePrecisionFloat = require "st.zigbee.data_types.SinglePrecisionFloat" +local zcl_global_commands = require "st.zigbee.zcl.global_commands" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local IASZone = zcl_clusters.IASZone +local CarbonMonoxideCluster = zcl_clusters.CarbonMonoxide +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement +local IASWD = zcl_clusters.IASWD +local SirenConfiguration = IASWD.types.SirenConfiguration +local carbonMonoxide = capabilities.carbonMonoxideDetector +local alarm = capabilities.alarm +local smokeDetector = capabilities.smokeDetector +local carbonMonoxideMeasurement = capabilities.carbonMonoxideMeasurement +local tamperAlert = capabilities.tamperAlert + +local ALARM_COMMAND = "alarmCommand" +local DEFAULT_MAX_WARNING_DURATION = 0x00F0 +local CarbonMonoxideEndpoint = 0x2E +local SmokeAlarmEndpoint = 0x23 +local TEMPERATURE_ENDPOINT = 0x26 + +local alarm_command = { + OFF = 0, + SIREN = 1 +} + +local CONFIGURATIONS = { + { + cluster = CarbonMonoxideCluster.ID, + attribute = CarbonMonoxideCluster.attributes.MeasuredValue.ID, + minimum_interval = 30, + maximum_interval = 600, + data_type = data_types.SinglePrecisionFloat, + reportable_change = SinglePrecisionFloat(0, -20, 0.048576) -- 0, -20, 0.048576 is 1ppm in SinglePrecisionFloat + } +} + +local function get_current_max_warning_duration(device) + return device.preferences.maxWarningDuration == nil and DEFAULT_MAX_WARNING_DURATION or device.preferences.maxWarningDuration +end + +local function device_added(driver, device) + device:emit_event(alarm.alarm.off()) + device:emit_event(smokeDetector.smoke.clear()) + device:emit_event(carbonMonoxide.carbonMonoxide.clear()) + device:emit_event(tamperAlert.tamper.clear()) + device:emit_event(carbonMonoxideMeasurement.carbonMonoxideLevel({value = 0, unit = "ppm"})) +end + +local function device_init(driver, device) + battery_defaults.build_linear_voltage_init(2.6, 3.1)(driver, device) + if CONFIGURATIONS ~= nil then + for _, attribute in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(attribute) + end + end +end + +local function generate_event_from_zone_status(driver, device, zone_status, zigbee_message) + local endpoint = zigbee_message.address_header.src_endpoint.value + if endpoint == SmokeAlarmEndpoint then + if zone_status:is_test_set() then + device:emit_event(smokeDetector.smoke.tested()) + elseif zone_status:is_alarm1_set() then + device:emit_event(smokeDetector.smoke.detected()) + else + device.thread:call_with_delay(6, function () + device:emit_event(smokeDetector.smoke.clear()) + end) + end + elseif endpoint == CarbonMonoxideEndpoint then + if zone_status:is_test_set() then + device:emit_event(carbonMonoxide.carbonMonoxide.tested()) + elseif zone_status:is_alarm1_set() then + device:emit_event(carbonMonoxide.carbonMonoxide.detected()) + else + device.thread:call_with_delay(6, function () + device:emit_event(carbonMonoxide.carbonMonoxide.clear()) + end) + end + end + if zone_status:is_tamper_set() then + device:emit_event(tamperAlert.tamper.detected()) + else + device:emit_event(tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function carbon_monoxide_measure_value_attr_handler(driver, device, attr_val, zb_rx) + local co_value = attr_val.value + if co_value <= 1 then + co_value = co_value * 1000000 + else + return + end + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, carbonMonoxideMeasurement.carbonMonoxideLevel({value = co_value, unit = "ppm"})) +end + +local function do_configure(driver, device) + device:configure() + local maxWarningDuration = get_current_max_warning_duration(device) + device:send(IASWD.attributes.MaxDuration:write(device, maxWarningDuration):to_endpoint(0x23)) + + device.thread:call_with_delay(5, function() + device:refresh() + end) +end + +local function send_siren_command(device, warning_mode, warning_siren_level) + local warning_duration = get_current_max_warning_duration(device) + local siren_configuration + + siren_configuration = SirenConfiguration(0x00) + siren_configuration:set_warning_mode(warning_mode) + siren_configuration:set_siren_level(warning_siren_level) + + device:send( + IASWD.server.commands.StartWarning( + device, + siren_configuration, + data_types.Uint16(warning_duration), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + ) +end + +local function siren_switch_off_handler(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.OFF, {persist = true}) + send_siren_command(device, 0x00, 0x00) +end + +local function siren_alarm_siren_handler(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.SIREN, {persist = true}) + send_siren_command(device, 0x01 , 0x01) + + local warningDurationDelay = get_current_max_warning_duration(device) + + device.thread:call_with_delay(warningDurationDelay, function() -- Send command to switch from siren to off in the app when the siren is done + if(device:get_field(ALARM_COMMAND) == alarm_command.SIREN) then + siren_switch_off_handler(driver, device, command) + end + end) +end + +local emit_alarm_event = function(device, cmd) + if cmd == alarm_command.OFF then + device:emit_event(capabilities.alarm.alarm.off()) + elseif cmd == alarm_command.SIREN then + device:emit_event(capabilities.alarm.alarm.siren()) + end +end + +local default_response_handler = function(driver, device, zigbee_message) + local is_success = zigbee_message.body.zcl_body.status.value + local command = zigbee_message.body.zcl_body.cmd.value + local alarm_ev = device:get_field(ALARM_COMMAND) + + if command == IASWD.server.commands.StartWarning.ID and is_success == Status.SUCCESS then + if alarm_ev ~= alarm_command.OFF then + emit_alarm_event(device, alarm_ev) + local lastDuration = get_current_max_warning_duration(device) + device.thread:call_with_delay(lastDuration, function(d) + device:emit_event(capabilities.alarm.alarm.off()) + end) + else + emit_alarm_event(device,alarm_command.OFF) + end + end +end + +local function info_changed(driver, device, event, args) + for name, info in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "maxWarningDuration") then + local input = device.preferences.maxWarningDuration + device:send(IASWD.attributes.MaxDuration:write(device, input)) + elseif (name == "temperatureSensitivity") then + local sensitivity = device.preferences.temperatureSensitivity + local temperatureSensitivity = math.floor(sensitivity * 100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 600, temperatureSensitivity):to_endpoint(TEMPERATURE_ENDPOINT)) + end + end + end +end + +local frient_smoke_carbon_monoxide = { + NAME = "Frient Smoke Carbon Monoxide", + lifecycle_handlers = { + added = device_added, + init = device_init, + configure = do_configure, + infoChanged = info_changed, + }, + capability_handlers = { + [alarm.ID] = { + [alarm.commands.off.NAME] = siren_switch_off_handler, + [alarm.commands.siren.NAME] = siren_alarm_siren_handler + } + }, + zigbee_handlers = { + global = { + [IASWD.ID] = { + [zcl_global_commands.DEFAULT_RESPONSE_ID] = default_response_handler + } + }, + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = generate_event_from_zone_status + }, + [CarbonMonoxideCluster.ID] = { + [CarbonMonoxideCluster.attributes.MeasuredValue.ID] = carbon_monoxide_measure_value_attr_handler + } + } + }, + can_handle = require("frient.can_handle"), +} + +return frient_smoke_carbon_monoxide \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/init.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/init.lua index 21c170151e..8a4f5b5c01 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/init.lua +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local ZigbeeDriver = require "st.zigbee" local capabilities = require "st.capabilities" @@ -22,10 +12,16 @@ local zigbee_carbon_monoxide_driver_template = { supported_capabilities = { capabilities.carbonMonoxideDetector, capabilities.battery, + capabilities.carbonMonoxideMeasurement, + capabilities.temperatureMeasurement, + capabilities.smokeDetector, + capabilities.tamperAlert, + capabilities.alarm }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, - sub_drivers = { require("ClimaxTechnology") }, health_check = false, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_carbon_monoxide_driver_template, diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/sub_drivers.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/sub_drivers.lua new file mode 100644 index 0000000000..c826ab7d00 --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +local sub_drivers = { + lazy_load_if_possible("ClimaxTechnology"), + lazy_load_if_possible("frient"), +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_climax_technology_carbon_monoxide.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_climax_technology_carbon_monoxide.lua index a2ba57dfed..fb92f80e90 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_climax_technology_carbon_monoxide.lua +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_climax_technology_carbon_monoxide.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -52,7 +41,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_frient_co_smoke_temperature_battery.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_frient_co_smoke_temperature_battery.lua new file mode 100644 index 0000000000..36bad5633a --- /dev/null +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_frient_co_smoke_temperature_battery.lua @@ -0,0 +1,515 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local IASZone = clusters.IASZone +local IASWD = clusters.IASWD +local CarbonMonoxideCluster = clusters.CarbonMonoxide +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local capabilities = require "st.capabilities" +local alarm = capabilities.alarm +local smokeDetector = capabilities.smokeDetector +local carbonMonoxideDetector = capabilities.carbonMonoxideDetector +local carbonMonoxideMeasurement = capabilities.carbonMonoxideMeasurement +local tamperAlert = capabilities.tamperAlert +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local data_types = require "st.zigbee.data_types" +local SinglePrecisionFloat = require "st.zigbee.data_types.SinglePrecisionFloat" +local device_management = require "st.zigbee.device_management" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local zcl_messages = require "st.zigbee.zcl" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local SMOKE_ENDPOINT = 0x23 +local CO_ENDPOINT = 0x2E +local TEMPERATURE_ENDPOINT = 0x26 +local ALARM_COMMAND = "alarmCommand" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-smoke-co-temperature-battery.yml"), + fingerprinted_endpoint_id = SMOKE_ENDPOINT, + zigbee_endpoints = { + [SMOKE_ENDPOINT] = { + id = SMOKE_ENDPOINT, + manufacturer = "frient A/S", + model = "SCAZB-143", + server_clusters = { PowerConfiguration.ID, IASZone.ID, IASWD.ID } + }, + [CO_ENDPOINT] = { + id = CO_ENDPOINT, + server_clusters = { IASZone.ID, CarbonMonoxideCluster.ID } + }, + [TEMPERATURE_ENDPOINT] = { + id = TEMPERATURE_ENDPOINT, + server_clusters = { TemperatureMeasurement.ID } + } + } + } +) + +local function build_default_response_msg(cluster, command, status, endpoint) + local addr_header = messages.AddressHeader( + mock_device:get_short_address(), + endpoint or SMOKE_ENDPOINT, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local default_response_body = default_response.DefaultResponse(command, status) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(default_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = default_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + +local function expect_bind_and_config(config, endpoint) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, config.cluster, zigbee_test_utils.mock_hub_eui, endpoint):to_endpoint(endpoint) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_config(mock_device, config):to_endpoint(endpoint) + }) +end + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle should set default states", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.off()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideDetector.carbonMonoxide.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideMeasurement.carbonMonoxideLevel({ value = 0, unit = "ppm" })) + ) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "init and doConfigure should bind, configure, and refresh", + function() + local battery_config = { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryVoltage.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = data_types.Uint8, + reportable_change = 1 + } + local ias_zone_config = { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 0, + maximum_interval = 180, + data_type = IASZone.attributes.ZoneStatus.base_type + } + local co_config = { + cluster = CarbonMonoxideCluster.ID, + attribute = CarbonMonoxideCluster.attributes.MeasuredValue.ID, + minimum_interval = 30, + maximum_interval = 600, + data_type = data_types.SinglePrecisionFloat, + reportable_change = SinglePrecisionFloat(0, -20, 0.048576) + } + local temp_config = { + cluster = TemperatureMeasurement.ID, + attribute = TemperatureMeasurement.attributes.MeasuredValue.ID, + minimum_interval = 30, + maximum_interval = 600, + data_type = data_types.Int16, + reportable_change = data_types.Int16(100) + } + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, PowerConfiguration.ID, PowerConfiguration.attributes.BatteryVoltage.ID):to_endpoint(SMOKE_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, IASZone.ID, IASZone.attributes.ZoneStatus.ID):to_endpoint(SMOKE_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, IASZone.ID, IASZone.attributes.ZoneStatus.ID):to_endpoint(CO_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, CarbonMonoxideCluster.ID, CarbonMonoxideCluster.attributes.MeasuredValue.ID):to_endpoint(CO_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.attr_refresh(mock_device, TemperatureMeasurement.ID, TemperatureMeasurement.attributes.MeasuredValue.ID):to_endpoint(TEMPERATURE_ENDPOINT) + }) + + expect_bind_and_config(battery_config, SMOKE_ENDPOINT) + expect_bind_and_config(ias_zone_config, SMOKE_ENDPOINT) + expect_bind_and_config(ias_zone_config, CO_ENDPOINT) + expect_bind_and_config(co_config, CO_ENDPOINT) + expect_bind_and_config(temp_config, TEMPERATURE_ENDPOINT) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device, 0x00, 0x00) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "IAS Zone smoke detected should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001):from_endpoint(SMOKE_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.detected()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "IAS Zone smoke tested should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0100):from_endpoint(SMOKE_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.tested()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "IAS Zone smoke clear should be delayed", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000):from_endpoint(SMOKE_ENDPOINT) + }) + + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "IAS Zone carbon monoxide detected should be handled", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001):from_endpoint(CO_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideDetector.carbonMonoxide.detected()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "IAS Zone carbon monoxide tested should be handled", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0100):from_endpoint(CO_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideDetector.carbonMonoxide.tested()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "IAS Zone carbon monoxide clear should be delayed", + function() + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000):from_endpoint(CO_ENDPOINT) + }) + + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideDetector.carbonMonoxide.clear()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Tamper detected should be handled", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004):from_endpoint(SMOKE_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.detected()) + ) + end +) + +test.register_coroutine_test( + "Tamper clear should be handled", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000):from_endpoint(SMOKE_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", tamperAlert.tamper.clear()) + ) + end +) + +test.register_coroutine_test( + "Carbon monoxide measurement should scale values <= 1", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + CarbonMonoxideCluster.attributes.MeasuredValue:build_test_attr_report( + mock_device, + SinglePrecisionFloat(0, -20, 0.048576) + ):from_endpoint(CO_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideMeasurement.carbonMonoxideLevel({ value = 0.99999999747524, unit = "ppm" })) + ) + end +) + +test.register_coroutine_test( + "Carbon monoxide measurement should pass through values > 1", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + CarbonMonoxideCluster.attributes.MeasuredValue:build_test_attr_report( + mock_device, + SinglePrecisionFloat(0, -15, 0.572864) + ):from_endpoint(CO_ENDPOINT) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", carbonMonoxideMeasurement.carbonMonoxideLevel({ value = 47.999998059822, unit = "ppm" })) + ) + end +) + +test.register_coroutine_test( + "infoChanged should update maxWarningDuration and temperatureSensitivity", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + local updates = { + preferences = { + maxWarningDuration = 120, + temperatureSensitivity = 1.3 + } + } + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write(mock_device, 120) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device, + 30, + 600, + 130 + ):to_endpoint(TEMPERATURE_ENDPOINT) + }) + end +) + +test.register_coroutine_test( + "Alarm siren command should send StartWarning and auto-off", + function() + mock_device.preferences.maxWarningDuration = 5 + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + local expected_configuration = IASWD.types.SirenConfiguration(0x00) + expected_configuration:set_warning_mode(0x01) + expected_configuration:set_siren_level(0x01) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expected_configuration, + data_types.Uint16(5), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) + + test.wait_for_events() + test.mock_time.advance_time(5) + + local expected_off_configuration = IASWD.types.SirenConfiguration(0x00) + expected_off_configuration:set_warning_mode(0x00) + expected_off_configuration:set_siren_level(0x00) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expected_off_configuration, + data_types.Uint16(5), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) + end +, + { + min_api_version = 17 + }) + +test.register_coroutine_test( + "Alarm off command should send StartWarning stop", + function() + mock_device.preferences.maxWarningDuration = 5 + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + local expected_configuration = IASWD.types.SirenConfiguration(0x00) + expected_configuration:set_warning_mode(0x00) + expected_configuration:set_siren_level(0x00) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expected_configuration, + data_types.Uint16(5), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) + end +, + { + min_api_version = 17 + }) + +test.register_coroutine_test( + "Default response to StartWarning should emit alarm events", + function() + mock_device.preferences.maxWarningDuration = 2 + mock_device:set_field(ALARM_COMMAND, 1, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_msg(IASWD.ID, IASWD.server.commands.StartWarning.ID, Status.SUCCESS, SMOKE_ENDPOINT) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.siren()) + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", alarm.alarm.off()) + ) + test.wait_for_events() + end +, + { + min_api_version = 17 + }) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_zigbee_carbon_monoxide.lua b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_zigbee_carbon_monoxide.lua index 313f3ea9ec..ab593688c3 100644 --- a/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_zigbee_carbon_monoxide.lua +++ b/drivers/SmartThings/zigbee-carbon-monoxide-detector/src/test/test_zigbee_carbon_monoxide.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -53,6 +42,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "carbonMonoxideDetector", capability_attr_id = "carbonMonoxide" } } }, + }, + { + min_api_version = 14 } ) @@ -77,6 +69,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "carbonMonoxideDetector", capability_attr_id = "carbonMonoxide" } } }, + }, + { + min_api_version = 14 } ) @@ -102,6 +97,9 @@ test.register_message_test( } }, + }, + { + min_api_version = 14 } ) @@ -126,6 +124,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "carbonMonoxideDetector", capability_attr_id = "carbonMonoxide" } } }, + }, + { + min_api_version = 14 } ) @@ -142,6 +143,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -210,7 +214,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-contact/fingerprints.yml b/drivers/SmartThings/zigbee-contact/fingerprints.yml index f35ca2f7ba..56e1599c96 100644 --- a/drivers/SmartThings/zigbee-contact/fingerprints.yml +++ b/drivers/SmartThings/zigbee-contact/fingerprints.yml @@ -154,6 +154,11 @@ zigbeeManufacturer: manufacturer: frient A/S model: WISZB-131 deviceProfileName: frient-contact-battery-temperature + - id: "frient A/S/WISZB-137" + deviceLabel: frient Vibration Sensor + manufacturer: frient A/S + model: WISZB-137 + deviceProfileName: acceleration-motion-temperature-battery - id: "Compacta/ZBWDS" deviceLabel: Smartenit Open/Closed Sensor manufacturer: Compacta @@ -199,11 +204,26 @@ zigbeeManufacturer: manufacturer: Third Reality, Inc model: 3RVS01031Z deviceProfileName: thirdreality-multi-sensor + - id: "SONOFF/SNZB-04P" + deviceLabel: SONOFF Contact Sensor + manufacturer: eWeLink + model: SNZB-04P + deviceProfileName: contact-battery-profile + - id: "SONOFF/SNZB-04PR2" + deviceLabel: SONOFF Contact Sensor + manufacturer: SONOFF + model: SNZB-04PR2 + deviceProfileName: contact-battery-profile - id: "Aug. Winkhaus SE/FM.V.ZB" deviceLabel: Funkkontakt FM.V.ZB manufacturer: Aug. Winkhaus SE model: FM.V.ZB deviceProfileName: contact-battery-profile + - id: "MultIR/MIR-MC100" + deviceLabel: MultiIR Contact Sensor MIR-MC100 + manufacturer: MultIR + model: MIR-MC100 + deviceProfileName: contact-battery-tamper-no-fw-update zigbeeGeneric: - id: "contact-generic" deviceLabel: "Zigbee Contact Sensor" diff --git a/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-battery.yml b/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-battery.yml new file mode 100644 index 0000000000..4f90f8a11e --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-battery.yml @@ -0,0 +1,50 @@ +name: acceleration-motion-temperature-battery +components: +- id: main + capabilities: + - id: accelerationSensor + version: 1 + - id: motionSensor + version: 1 + - id: threeAxis + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor +preferences: + - preferenceId: tempOffset + explicit: true + - title: "Temperature sensitivity (°)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 + - title: "Sensitivity level" + name: sensitivityLevel + description: "How sensitivite the device is to vibrations" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 15 + default: 10 + - title: "Use with Contact Sensor" + name: garageSensor + required: false + preferenceType: enumeration + definition: + options: + "Yes": "Yes" + "No": "No" + default: "No" \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-contact-battery.yml b/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-contact-battery.yml new file mode 100644 index 0000000000..c40eb49b9e --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/acceleration-motion-temperature-contact-battery.yml @@ -0,0 +1,89 @@ +name: acceleration-motion-temperature-contact-battery +components: +- id: main + capabilities: + - id: accelerationSensor + version: 1 + - id: motionSensor + version: 1 + - id: threeAxis + version: 1 + - id: contactSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor +preferences: + - preferenceId: tempOffset + explicit: true + - title: "Temperature sensitivity (°)" + name: temperatureSensitivity + description: "Minimum change in temperature to report" + required: false + preferenceType: number + definition: + minimum: 0.1 + maximum: 2.0 + default: 1.0 + - title: "Sensitivity level" + name: sensitivityLevel + description: "How sensitivite the device is to vibrations" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 15 + default: 10 + - title: "Use with Contact Sensor" + name: garageSensor + required: false + preferenceType: enumeration + definition: + options: + "Yes": "Yes" + "No": "No" + default: "Yes" + - title: "Axis to activate Contact Sensor" + name: contactSensorAxis + required: false + preferenceType: enumeration + definition: + options: + "X": "X" + "Y": "Y" + "Z": "Z" + default: "Z" + - title: "Initial position (closed state)" + name: sensorInitialPosition + description: "Initial position of the device in the chosen axis" + required: false + preferenceType: number + definition: + minimum: -2000 + maximum: 2000 + default: 0 + - title: "Contact Sensor threshold (open)" + name: contactSensorValue + description: "Value change required to trigger contact sensor" + required: false + preferenceType: number + definition: + minimum: 20 + maximum: 4000 + default: 900 + - title: "Measurement tolerance" + name: tolerance + description: "Set the tolerance in percentage of the threshold" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 20 + default: 0 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper-no-fw-update.yml b/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper-no-fw-update.yml new file mode 100644 index 0000000000..528e6c0021 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper-no-fw-update.yml @@ -0,0 +1,14 @@ +name: contact-battery-tamper-no-fw-update +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: battery + version: 1 + - id: tamperAlert + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/zigbee-contact/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-contact/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..4bda1794f0 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-MC100" } +} diff --git a/drivers/SmartThings/zigbee-contact/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-contact/src/MultiIR/init.lua new file mode 100644 index 0000000000..1481bb01fc --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/MultiIR/init.lua @@ -0,0 +1,52 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local IASZone = clusters.IASZone + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + device:emit_event(zone_status:is_alarm1_set() and capabilities.contactSensor.contact.open() or capabilities.contactSensor.contact.closed()) + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device:emit_event(zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_attr_handler(driver, device, attr_val, zb_rx) + generate_event_from_zone_status(driver, device, attr_val, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + +local function added_handler(driver, device) + device:emit_event(capabilities.battery.battery(100)) + device:emit_event(capabilities.contactSensor.contact.closed()) + if device:supports_capability_by_id(capabilities.tamperAlert.ID) then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local MultiIR_sensor = { + NAME = "MultiIR Contact Sensor", + lifecycle_handlers = { + added = added_handler + }, + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler, + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler, + } + } + }, + can_handle = require("MultiIR.can_handle") +} + +return MultiIR_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/aqara/can_handle.lua new file mode 100644 index 0000000000..7877f2c180 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device, ...) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-contact/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..bcad0d779d --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.magnet.agl02" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/aqara/init.lua b/drivers/SmartThings/zigbee-contact/src/aqara/init.lua index 477a62afd4..9093ee7bb9 100644 --- a/drivers/SmartThings/zigbee-contact/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" @@ -13,9 +16,6 @@ local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local PRIVATE_HEART_BATTERY_ENERGY_ID = 0x00F7 -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.magnet.agl02" } -} local CONFIGURATIONS = { { @@ -36,14 +36,6 @@ local CONFIGURATIONS = { } } -local is_aqara_products = function(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) device:remove_configured_attribute(IASZone.ID, IASZone.attributes.ZoneStatus.ID) @@ -136,7 +128,7 @@ local aqara_contact_handler = { doConfigure = do_configure, added = added_handler, }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_contact_handler diff --git a/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/can_handle.lua new file mode 100644 index 0000000000..ceb3f4d933 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aurora_contact(opts, driver, device, ...) + local FINGERPRINTS = require("aurora-contact-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aurora-contact-sensor") + end + end + return false +end + +return can_handle_aurora_contact diff --git a/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/fingerprints.lua new file mode 100644 index 0000000000..022bbc83d5 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AURORA_CONTACT_FINGERPRINTS = { + { mfr = "Aurora", model = "DoorSensor50AU" }, -- Aurora Smart Door/Window Sensor + { mfr = "Aurora", model = "WindowSensor51AU" } -- Aurora Smart Door/Window Sensor +} + +return AURORA_CONTACT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/init.lua b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/init.lua index efa6fb8c37..b5ac4fe8bb 100644 --- a/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/aurora-contact-sensor/init.lua @@ -1,26 +1,12 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local IASZone = clusters.IASZone -local AURORA_CONTACT_FINGERPRINTS = { - { mfr = "Aurora", model = "DoorSensor50AU" }, -- Aurora Smart Door/Window Sensor - { mfr = "Aurora", model = "WindowSensor51AU" } -- Aurora Smart Door/Window Sensor -} local AURORA_CONTACT_CONFIGURATION = { { @@ -33,14 +19,6 @@ local AURORA_CONTACT_CONFIGURATION = { } } -local function can_handle_aurora_contact(opts, driver, device, ...) - for _, fingerprint in ipairs(AURORA_CONTACT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.use_battery_voltage_handling(device) @@ -55,7 +33,7 @@ local aurora_contact = { lifecycle_handlers = { init = device_init }, - can_handle = can_handle_aurora_contact + can_handle = require("aurora-contact-sensor.can_handle"), } return aurora_contact diff --git a/drivers/SmartThings/zigbee-contact/src/configurations.lua b/drivers/SmartThings/zigbee-contact/src/configurations.lua index aa28e1e43f..668e8c5ac6 100644 --- a/drivers/SmartThings/zigbee-contact/src/configurations.lua +++ b/drivers/SmartThings/zigbee-contact/src/configurations.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" @@ -37,7 +26,6 @@ local devices = { EWELINK_HEIMAN = { FINGERPRINTS = { { mfr = "eWeLink", model = "DS01" }, - { mfr = "eWeLink", model = "SNZB-04P" }, { mfr = "HEIMAN", model = "DoorSensor-N" } }, CONFIGURATION = { @@ -129,6 +117,22 @@ local devices = { } } }, + FRIENT_VIBRATION_SENSOR_WISZB_137 = { + FINGERPRINTS = { + { mfr = "frient A/S", model = "WISZB-137" } + }, + CONFIGURATION = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 0, + maximum_interval = 3600, + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1, + endpoint = 0x2D + } + } + } } local configurations = {} diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/can_handle.lua new file mode 100644 index 0000000000..2c9f359dbe --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_contact_temperature_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("contact-temperature-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("contact-temperature-sensor") + end + end + return false +end + +return can_handle_contact_temperature_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/can_handle.lua new file mode 100644 index 0000000000..b17c1efb21 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ecolink_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("contact-temperature-sensor.ecolink-contact.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("contact-temperature-sensor.ecolink-contact") + end + end + return false +end + +return can_handle_ecolink_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/fingerprints.lua new file mode 100644 index 0000000000..c86e6e073f --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ECOLINK_CONTACT_TEMPERATURE_FINGERPRINTS = { + { mfr = "Ecolink", model = "4655BC0-R" }, + { mfr = "Ecolink", model = "DWZB1-ECO" } +} + +return ECOLINK_CONTACT_TEMPERATURE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/init.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/init.lua index 22999c59aa..31dc67fd3e 100644 --- a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/ecolink-contact/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local device_management = require "st.zigbee.device_management" @@ -22,19 +12,7 @@ local SHORT_POLL_INTERVAL = 0x0200 local LONG_POLL_INTERVAL = 0xB1040000 local FAST_POLL_TIMEOUT = 0x0028 -local ECOLINK_CONTACT_TEMPERATURE_FINGERPRINTS = { - { mfr = "Ecolink", model = "4655BC0-R" }, - { mfr = "Ecolink", model = "DWZB1-ECO" } -} -local function can_handle_ecolink_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(ECOLINK_CONTACT_TEMPERATURE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function do_configure(driver, device) device:configure() @@ -51,7 +29,7 @@ local ecolink_sensor = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_ecolink_sensor + can_handle = require("contact-temperature-sensor.ecolink-contact.can_handle"), } return ecolink_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/fingerprints.lua new file mode 100644 index 0000000000..4efc09684b --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/fingerprints.lua @@ -0,0 +1,24 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CONTACT_TEMPERATURE_SENSOR_FINGERPRINTS = { + { mfr = "CentraLite", model = "3300-S" }, + { mfr = "CentraLite", model = "3300" }, + { mfr = "CentraLite", model = "3320-L" }, + { mfr = "CentraLite", model = "3323-G" }, + { mfr = "CentraLite", model = "Contact Sensor-A" }, + { mfr = "Visonic", model = "MCT-340 E" }, + { mfr = "Visonic", model = "MCT-340 SMA" }, + { mfr = "Ecolink", model = "4655BC0-R" }, + { mfr = "Ecolink", model = "DWZB1-ECO" }, + { mfr = "iMagic by GreatStar", model = "1116-S" }, + { mfr = "Bosch", model = "RFMS-ZBMS" }, + { mfr = "Megaman", model = "MS601/z1" }, + { mfr = "AduroSmart Eria", model = "CSW_ADUROLIGHT" }, + { mfr = "ADUROLIGHT", model = "CSW_ADUROLIGHT" }, + { mfr = "Sercomm Corp.", model = "SZ-DWS04" }, + { mfr = "DAWON_DNS", model = "SS-B100-ZB" }, + { mfr = "Compacta", model = "ZBWDS" } +} + +return CONTACT_TEMPERATURE_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/init.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/init.lua index 33b2e3daa3..76f822fedd 100644 --- a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/init.lua @@ -1,48 +1,12 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local configurationMap = require "configurations" -local CONTACT_TEMPERATURE_SENSOR_FINGERPRINTS = { - { mfr = "CentraLite", model = "3300-S" }, - { mfr = "CentraLite", model = "3300" }, - { mfr = "CentraLite", model = "3320-L" }, - { mfr = "CentraLite", model = "3323-G" }, - { mfr = "CentraLite", model = "Contact Sensor-A" }, - { mfr = "Visonic", model = "MCT-340 E" }, - { mfr = "Visonic", model = "MCT-340 SMA" }, - { mfr = "Ecolink", model = "4655BC0-R" }, - { mfr = "Ecolink", model = "DWZB1-ECO" }, - { mfr = "iMagic by GreatStar", model = "1116-S" }, - { mfr = "Bosch", model = "RFMS-ZBMS" }, - { mfr = "Megaman", model = "MS601/z1" }, - { mfr = "AduroSmart Eria", model = "CSW_ADUROLIGHT" }, - { mfr = "ADUROLIGHT", model = "CSW_ADUROLIGHT" }, - { mfr = "Sercomm Corp.", model = "SZ-DWS04" }, - { mfr = "DAWON_DNS", model = "SS-B100-ZB" }, - { mfr = "Compacta", model = "ZBWDS" } -} -local function can_handle_contact_temperature_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(CONTACT_TEMPERATURE_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) @@ -61,10 +25,8 @@ local contact_temperature_sensor = { lifecycle_handlers = { init = device_init }, - sub_drivers = { - require("contact-temperature-sensor/ecolink-contact") - }, - can_handle = can_handle_contact_temperature_sensor + sub_drivers = require("contact-temperature-sensor.sub_drivers"), + can_handle = require("contact-temperature-sensor.can_handle"), } return contact_temperature_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/sub_drivers.lua b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/sub_drivers.lua new file mode 100644 index 0000000000..d47c4ad123 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/contact-temperature-sensor/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("contact-temperature-sensor.ecolink-contact"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-contact/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/frient/can_handle.lua new file mode 100644 index 0000000000..e2d56c3dc0 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if (device:get_manufacturer() == "frient A/S") and + (device:get_model() == "WISZB-120" or + device:get_model() == "WISZB-121" or + device:get_model() == "WISZB-131" or + device:get_model() == "WISZB-137") then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/can_handle.lua new file mode 100644 index 0000000000..14ecae440a --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FRIENT_DEVICE_FINGERPRINTS = { + { mfr = "frient A/S", model = "WISZB-137", }, +} + +return function(opts, driver, device, ...) + for _, fingerprint in ipairs(FRIENT_DEVICE_FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("frient.frient-vibration") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/init.lua b/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/init.lua new file mode 100644 index 0000000000..bba3dc0bdf --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/frient-vibration/init.lua @@ -0,0 +1,212 @@ +-- Copyright 2025 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. + +local capabilities = require "st.capabilities" +local zcl_commands = require "st.zigbee.zcl.global_commands" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" +local device_management = require "st.zigbee.device_management" +local data_types = require "st.zigbee.data_types" +local threeAxis = capabilities.threeAxis + +local TemperatureMeasurement = zcl_clusters.TemperatureMeasurement +local IASZone = zcl_clusters.IASZone +local PowerConfiguration = zcl_clusters.PowerConfiguration +local POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT = 0x2D +local TEMPERATURE_ENDPOINT = 0x26 + +local Frient_AccelerationMeasurementCluster = { + ID = 0xFC04, + ManufacturerSpecificCode = 0x1015, + attributes = { + MeasuredValueX = { ID = 0x0000, data_type = data_types.Int16 }, + MeasuredValueY = { ID = 0x0001, data_type = data_types.Int16 }, + MeasuredValueZ = { ID = 0x0002, data_type = data_types.Int16 } + }, +} + +local function acceleration_measure_report_handler(driver, device, zb_rx) + local measured_x, measured_y, measured_z + + for _, attribute_record in ipairs(zb_rx.body.zcl_body.attr_records) do + local attribute_id = attribute_record.attr_id.value + local axis_value = attribute_record.data.value + + if attribute_id == Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID then + measured_x = axis_value + elseif attribute_id == Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID then + measured_y = axis_value + elseif attribute_id == Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID then + measured_z = axis_value + end + end + + if measured_x and measured_y and measured_z then + device:emit_event(threeAxis.threeAxis({measured_x, measured_y, measured_z})) + + if device:supports_capability(capabilities.contactSensor) then + local garageAxis = measured_x + if device.preferences.contactSensorAxis == "Y" then + garageAxis = measured_y + elseif device.preferences.contactSensorAxis == "Z" then + garageAxis = measured_z + end + local initial_position = device.preferences.sensorInitialPosition or 0 + if math.abs(initial_position - garageAxis) >= device.preferences.contactSensorValue - device.preferences.contactSensorValue * (device.preferences.tolerance / 100) then + device:emit_event(capabilities.contactSensor.contact.open()) + else + device:emit_event(capabilities.contactSensor.contact.closed()) + end + end + end +end + +local function get_cluster_configurations() + return { + { + cluster = Frient_AccelerationMeasurementCluster.ID, + attribute = Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID, + minimum_interval = 0, + maximum_interval = 300, + reportable_change = 0x0001, + data_type = data_types.Int16, + mfg_code = Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + }, + { + cluster = Frient_AccelerationMeasurementCluster.ID, + attribute = Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID, + minimum_interval = 0, + maximum_interval = 300, + reportable_change = 0x0001, + data_type = data_types.Int16, + mfg_code = Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + }, + { + cluster = Frient_AccelerationMeasurementCluster.ID, + attribute = Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID, + minimum_interval = 0, + maximum_interval = 300, + reportable_change = 0x0001, + data_type = data_types.Int16, + mfg_code = Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + } + } +end + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + device:emit_event(zone_status:is_alarm1_set() and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) + device:emit_event(zone_status:is_alarm2_set() and capabilities.accelerationSensor.acceleration.active() or capabilities.accelerationSensor.acceleration.inactive()) +end + +local function ias_zone_status_attr_handler(driver, device, attr_val, zb_rx) + generate_event_from_zone_status(driver, device, attr_val, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) +end + +local function device_init(driver, device) + battery_defaults.build_linear_voltage_init(2.3, 3.0)(driver, device) + --Add the manufacturer-specific attributes to generate their configure reporting and bind requests + for _, config in pairs(get_cluster_configurations()) do + device:add_configured_attribute(config) + end +end + +local function do_refresh(driver, device) + device:send(IASZone.attributes.ZoneStatus:read(device):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, Frient_AccelerationMeasurementCluster.ID, Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID, Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, Frient_AccelerationMeasurementCluster.ID, Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID, Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, Frient_AccelerationMeasurementCluster.ID, Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID, Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(TemperatureMeasurement.attributes.MeasuredValue:read(device):to_endpoint(TEMPERATURE_ENDPOINT)) + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) +end + +local function do_configure(driver, device, event, args) + device:configure() + + device:send(device_management.build_bind_request(device, zcl_clusters.IASZone.ID, driver.environment_info.hub_zigbee_eui, POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(IASZone.attributes.ZoneStatus:configure_reporting(device, 0, 1*60*60, 1):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + device:send(device_management.build_bind_request(device, Frient_AccelerationMeasurementCluster.ID, driver.environment_info.hub_zigbee_eui, POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + + local sensitivityLevel = device.preferences.sensitivityLevel or 10 + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:write(device, sensitivityLevel):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT)) + + local sensitivity = math.floor((device.preferences.temperatureSensitivity or 0.1) * 100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 1 * 60 * 60, sensitivity):to_endpoint(TEMPERATURE_ENDPOINT)) + + device.thread:call_with_delay(5, function() + device:refresh() + end) +end + +local function info_changed(driver, device, event, args) + if args and args.old_st_store then + if args.old_st_store.preferences.sensitivityLevel ~= device.preferences.sensitivityLevel then + local sensitivityLevel = device.preferences.sensitivityLevel or 10 + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:write(device, sensitivityLevel):to_endpoint(0x2D)) + end + if args.old_st_store.preferences.temperatureSensitivity ~= device.preferences.temperatureSensitivity then + local sensitivity = math.floor((device.preferences.temperatureSensitivity or 0.1)*100 + 0.5) + device:send(TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(device, 30, 1*60*60, sensitivity):to_endpoint(0x26)) + end + if args.old_st_store.preferences.garageSensor ~= device.preferences.garageSensor then + if device.preferences.garageSensor == "Yes" then + device:try_update_metadata({profile = "acceleration-motion-temperature-contact-battery"}) + elseif device.preferences.garageSensor == "No" then + device:try_update_metadata({profile = "acceleration-motion-temperature-battery"}) + end + end + device.thread:call_with_delay(5, function() + device:refresh() + end) + end +end + +local frient_vibration_driver_template = { + NAME = "frient vibration driver", + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure, + infoChanged = info_changed + }, + zigbee_handlers = { + global = { + [Frient_AccelerationMeasurementCluster.ID] = { + [zcl_commands.ReportAttribute.ID] = acceleration_measure_report_handler, + [zcl_commands.ReadAttributeResponse.ID] = acceleration_measure_report_handler + } + }, + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + } + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + } + }, + can_handle = require ("frient.frient-vibration.can_handle") +} + +return frient_vibration_driver_template diff --git a/drivers/SmartThings/zigbee-contact/src/frient/init.lua b/drivers/SmartThings/zigbee-contact/src/frient/init.lua index e732664d91..f28cad8cdb 100644 --- a/drivers/SmartThings/zigbee-contact/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/frient/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" @@ -80,6 +70,7 @@ local frient_sensor = { doConfigure = do_configure, infoChanged = info_changed }, + sub_drivers = require("frient.sub_drivers"), zigbee_handlers = { cluster = { [IASZone.ID] = { @@ -92,9 +83,7 @@ local frient_sensor = { } } }, - can_handle = function(opts, driver, device, ...) - return (device:get_manufacturer() == "frient A/S" and (device:get_model() == "WISZB-120" or device:get_model() == "WISZB-121" or device:get_model() == "WISZB-131")) - end + can_handle = require("frient.can_handle"), } return frient_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/frient/sub_drivers.lua b/drivers/SmartThings/zigbee-contact/src/frient/sub_drivers.lua new file mode 100644 index 0000000000..cfaca8fca3 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/frient/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient.frient-vibration"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-contact/src/init.lua b/drivers/SmartThings/zigbee-contact/src/init.lua index 67be42e671..0258d5360a 100644 --- a/drivers/SmartThings/zigbee-contact/src/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -97,17 +87,10 @@ local zigbee_contact_driver_template = { init = device_init, added = added_handler, }, - sub_drivers = { - require("aqara"), - require("aurora-contact-sensor"), - require("contact-temperature-sensor"), - require("multi-sensor"), - require("smartsense-multi"), - require("sengled"), - require("frient") - }, + sub_drivers = require("sub_drivers"), ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_contact_driver_template, diff --git a/drivers/SmartThings/zigbee-contact/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-contact/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/can_handle.lua new file mode 100644 index 0000000000..0065632f88 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zigbee_multi_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("multi-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("multi-sensor") + end + end + return false +end + +return can_handle_zigbee_multi_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/can_handle.lua new file mode 100644 index 0000000000..94e73b1691 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function centralite_multi_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "CentraLite" then + return true, require("multi-sensor.centralite-multi") + end + return false +end + +return centralite_multi_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/init.lua index 1e75aa6b0d..0f7b7b7f25 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/centralite-multi/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local multi_utils = require "multi-sensor/multi_utils" @@ -67,9 +57,7 @@ local centralite_handler = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "CentraLite" - end + can_handle = require("multi-sensor.centralite-multi.can_handle"), } return centralite_handler diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/fingerprints.lua new file mode 100644 index 0000000000..7abfe772b5 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local MULTI_SENSOR_FINGERPRINTS = { + { mfr = "CentraLite", model = "3320" }, + { mfr = "CentraLite", model = "3321" }, + { mfr = "CentraLite", model = "3321-S" }, + { mfr = "SmartThings", model = "multiv4" }, + { mfr = "Samjin", model = "multi" }, + { mfr = "Third Reality, Inc", model = "3RVS01031Z" } +} + +return MULTI_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/init.lua index a440156c11..4cc8f88efa 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local zcl_commands = require "st.zigbee.zcl.global_commands" local multi_utils = require "multi-sensor/multi_utils" @@ -18,23 +9,7 @@ local zcl_clusters = require "st.zigbee.zcl.clusters" local contactSensor_defaults = require "st.zigbee.defaults.contactSensor_defaults" local capabilities = require "st.capabilities" -local MULTI_SENSOR_FINGERPRINTS = { - { mfr = "CentraLite", model = "3320" }, - { mfr = "CentraLite", model = "3321" }, - { mfr = "CentraLite", model = "3321-S" }, - { mfr = "SmartThings", model = "multiv4" }, - { mfr = "Samjin", model = "multi" }, - { mfr = "Third Reality, Inc", model = "3RVS01031Z" } -} -local function can_handle_zigbee_multi_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(MULTI_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function multi_sensor_report_handler(driver, device, zb_rx) local x, y, z @@ -98,12 +73,7 @@ local multi_sensor = { } } }, - sub_drivers = { - require("multi-sensor/smartthings-multi"), - require("multi-sensor/samjin-multi"), - require("multi-sensor/centralite-multi"), - require("multi-sensor/thirdreality-multi") - }, - can_handle = can_handle_zigbee_multi_sensor + sub_drivers = require("multi-sensor.sub_drivers"), + can_handle = require("multi-sensor.can_handle"), } return multi_sensor diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/multi_utils.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/multi_utils.lua index e24ec1097b..b1b3e18be4 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/multi_utils.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/multi_utils.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cluster_base = require "st.zigbee.cluster_base" @@ -152,4 +141,4 @@ multi_utils.convert_to_signedInt16 = function(byte1, byte2) end -return multi_utils \ No newline at end of file +return multi_utils diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/can_handle.lua new file mode 100644 index 0000000000..e524845f40 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function samjin_multi_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Samjin" then + return true, require("multi-sensor.samjin-multi") + end + return false +end + +return samjin_multi_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/init.lua index 935d1cfc20..3bc3edbcf9 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/samjin-multi/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local data_types = require "st.zigbee.data_types" @@ -54,9 +44,7 @@ local samjin_driver = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Samjin" - end + can_handle = require("multi-sensor.samjin-multi.can_handle"), } return samjin_driver diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/can_handle.lua new file mode 100644 index 0000000000..6c99521efc --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function smartthings_multi_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "SmartThings" then + return true, require("multi-sensor.smartthings-multi") + end + return false +end + +return smartthings_multi_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/init.lua index 69d0b7f3f4..24234ef6de 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/smartthings-multi/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local zcl_commands = require "st.zigbee.zcl.global_commands" @@ -91,9 +81,7 @@ local smartthings_multi = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "SmartThings" - end + can_handle = require("multi-sensor.smartthings-multi.can_handle"), } return smartthings_multi diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/sub_drivers.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/sub_drivers.lua new file mode 100644 index 0000000000..3769ab89d1 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("multi-sensor/smartthings-multi"), + lazy_load_if_possible("multi-sensor/samjin-multi"), + lazy_load_if_possible("multi-sensor/centralite-multi"), + lazy_load_if_possible("multi-sensor/thirdreality-multi"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/can_handle.lua new file mode 100644 index 0000000000..bdfc4f75b5 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function thirdreality_multi_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Third Reality, Inc" then + return true, require("multi-sensor.thirdreality-multi") + end + return false +end + +return thirdreality_multi_can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/init.lua index c63596cab5..1be7988fac 100644 --- a/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/multi-sensor/thirdreality-multi/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_commands = require "st.zigbee.zcl.global_commands" local multi_utils = require "multi-sensor/multi_utils" @@ -47,9 +37,7 @@ local thirdreality_multi = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Third Reality, Inc" - end + can_handle = require("multi-sensor.thirdreality-multi.can_handle"), } return thirdreality_multi diff --git a/drivers/SmartThings/zigbee-contact/src/sengled/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/sengled/can_handle.lua new file mode 100644 index 0000000000..68d774fb8f --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/sengled/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sengled_products = function(opts, driver, device, ...) + local FINGERPRINTS = require("sengled.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sengled") + end + end + return false +end + +return is_sengled_products diff --git a/drivers/SmartThings/zigbee-contact/src/sengled/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/sengled/fingerprints.lua new file mode 100644 index 0000000000..4d015324b3 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/sengled/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "sengled", model = "E2D-G73" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/sengled/init.lua b/drivers/SmartThings/zigbee-contact/src/sengled/init.lua index 95b6e5e3e8..6a4b4c6814 100644 --- a/drivers/SmartThings/zigbee-contact/src/sengled/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/sengled/init.lua @@ -1,12 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration -local FINGERPRINTS = { - { mfr = "sengled", model = "E2D-G73" } -} local CONFIGURATIONS = { { @@ -27,14 +27,6 @@ local CONFIGURATIONS = { } } -local is_sengled_products = function(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.build_linear_voltage_init(2.1, 3.0)(driver, device) @@ -49,7 +41,7 @@ local sengled_contact_handler = { lifecycle_handlers = { init = device_init }, - can_handle = is_sengled_products + can_handle = require("sengled.can_handle"), } return sengled_contact_handler diff --git a/drivers/SmartThings/zigbee-contact/src/smartsense-multi/can_handle.lua b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/can_handle.lua new file mode 100644 index 0000000000..f5c226cc55 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device, ...) + local SMARTSENSE_PROFILE_ID = 0xFC01 + local FINGERPRINTS = require("smartsense-multi.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("smartsense-multi") + end + end + local endpoint = device.zigbee_endpoints[1] or device.zigbee_endpoints["1"] + if endpoint.profile_id == SMARTSENSE_PROFILE_ID then return true end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-contact/src/smartsense-multi/fingerprints.lua b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/fingerprints.lua new file mode 100644 index 0000000000..fff1b601a3 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SMARTSENSE_MULTI_FINGERPRINTS = { + { mfr = "SmartThings", model = "PGC313" }, + { mfr = "SmartThings", model = "PGC313EU" } +} + +return SMARTSENSE_MULTI_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-contact/src/smartsense-multi/init.lua b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/init.lua index cbe59d7c09..d5030278e8 100644 --- a/drivers/SmartThings/zigbee-contact/src/smartsense-multi/init.lua +++ b/drivers/SmartThings/zigbee-contact/src/smartsense-multi/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local multi_utils = require "multi-sensor/multi_utils" @@ -24,23 +13,6 @@ local SMARTSENSE_MULTI_ACC_CMD = 0x00 local SMARTSENSE_MULTI_XYZ_CMD = 0x05 local SMARTSENSE_MULTI_STATUS_CMD = 0x07 local SMARTSENSE_MULTI_STATUS_REPORT_CMD = 0x09 -local SMARTSENSE_PROFILE_ID = 0xFC01 - -local SMARTSENSE_MULTI_FINGERPRINTS = { - { mfr = "SmartThings", model = "PGC313" }, - { mfr = "SmartThings", model = "PGC313EU" } -} - -local function can_handle(opts, driver, device, ...) - for _, fingerprint in ipairs(SMARTSENSE_MULTI_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - local endpoint = device.zigbee_endpoints[1] or device.zigbee_endpoints["1"] - if endpoint.profile_id == SMARTSENSE_PROFILE_ID then return true end - return false -end local function acceleration_handler(driver, device, zb_rx) -- This is a custom cluster command for the kickstarter multi. @@ -182,7 +154,7 @@ local smartsense_multi = { } } }, - can_handle = can_handle + can_handle = require("smartsense-multi.can_handle"), } return smartsense_multi diff --git a/drivers/SmartThings/zigbee-contact/src/sub_drivers.lua b/drivers/SmartThings/zigbee-contact/src/sub_drivers.lua new file mode 100644 index 0000000000..8a4977ca5e --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/sub_drivers.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("aurora-contact-sensor"), + lazy_load_if_possible("contact-temperature-sensor"), + lazy_load_if_possible("multi-sensor"), + lazy_load_if_possible("smartsense-multi"), + lazy_load_if_possible("sengled"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("MultiIR"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_aqara_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_aqara_contact_sensor.lua index 119e3fbdab..014e2b8ed1 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_aqara_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_aqara_contact_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -75,7 +64,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE , data_types.Uint8, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -96,7 +88,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.quantity(1))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.battery("normal"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -111,7 +106,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.battery("normal"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -126,7 +124,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.battery("critical"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -141,7 +142,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.battery("normal"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -156,7 +160,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.battery("warning"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -171,7 +178,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.batteryLevel.battery("critical"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -186,7 +196,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -201,7 +214,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_aurora_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_aurora_contact_sensor.lua index 40cc2c47be..395b85063f 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_aurora_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_aurora_contact_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -53,7 +42,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +93,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_centralite_multi_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_centralite_multi_sensor.lua index e3628f6ec7..09f0108325 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_centralite_multi_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_centralite_multi_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -117,7 +106,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 200, 100})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -140,7 +132,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, acceleration_report_inactive, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -173,7 +168,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -197,7 +195,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_attribute_read(mock_device, 0xFC02, {0x0010}, 0x104E) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -218,7 +219,10 @@ test.register_coroutine_test( mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -276,7 +280,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -298,7 +305,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -321,7 +331,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device_old_firmware:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_contact_temperature_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_contact_temperature_sensor.lua index e05233ad48..b0ce27f524 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_contact_temperature_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_contact_temperature_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -55,7 +44,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -114,7 +106,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -130,6 +125,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -146,6 +144,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_ecolink_contact.lua b/drivers/SmartThings/zigbee-contact/src/test/test_ecolink_contact.lua index a05f420166..1341f0a518 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_ecolink_contact.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_ecolink_contact.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,7 +45,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -123,7 +115,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PollControl.commands.SetLongPollInterval(mock_device, 0xB1040000) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -139,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -155,6 +153,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_ewelink_heiman_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_ewelink_heiman_sensor.lua index 58c2c8d6b2..05d030b0d3 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_ewelink_heiman_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_ewelink_heiman_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -54,7 +43,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -102,7 +94,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor.lua index 7d1319abc0..e766f0a4d5 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -57,7 +46,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -141,7 +133,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -157,6 +152,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -173,39 +171,12 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) --- test.register_coroutine_test( --- "Health check should check all relevant attributes", --- function() --- test.wait_for_events() - --- test.mock_time.advance_time(50000) -- battery is 21600 for max reporting interval --- test.socket.zigbee:__set_channel_ordering("relaxed") - --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- PowerConfiguration.attributes.BatteryVoltage:read(mock_device) --- } --- ) - --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- IASZone.attributes.ZoneStatus:read(mock_device) --- } --- ) --- end, --- { --- test_init = function() --- test.mock_device.add_test_device(mock_device) --- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") --- end --- } --- ) - test.register_message_test( "Refresh should read all necessary attributes", { @@ -235,7 +206,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -252,6 +224,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -268,6 +243,47 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: contact/open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: contact/closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_2_pro.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_2_pro.lua index 12bfbc0b1e..3911b323c1 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_2_pro.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_2_pro.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -65,7 +54,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -184,7 +176,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -208,6 +203,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -224,6 +222,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -240,6 +241,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -317,7 +321,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -334,6 +339,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -350,6 +358,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -366,6 +377,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -382,6 +396,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -407,7 +424,10 @@ test.register_coroutine_test( temperatureSensitivity ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_pro.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_pro.lua index d9671a5283..e21f06f588 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_pro.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_frient_contact_sensor_pro.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -65,7 +54,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -184,7 +176,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -208,6 +203,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -224,6 +222,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -240,6 +241,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -317,7 +321,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -339,6 +344,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -360,6 +368,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -381,6 +392,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -402,6 +416,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -427,7 +444,10 @@ test.register_coroutine_test( temperatureSensitivity ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_frient_vibration_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_frient_vibration_sensor.lua new file mode 100644 index 0000000000..d386758433 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/test/test_frient_vibration_sensor.lua @@ -0,0 +1,541 @@ + +-- Copyright 2025 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. + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration +local TemperatureMeasurement = clusters.TemperatureMeasurement +local POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT = 0x2D +local TEMPERATURE_ENDPOINT = 0x26 + +local base64 = require "base64" +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("acceleration-motion-temperature-battery.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "WISZB-137", + server_clusters = { 0x0003, 0x0005, 0x0006 } + }, + [0x2D] = { + id = 0x2D, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x0020, 0x0500, 0xFC04 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0402 } + } + } + } +) + +local mock_device_contact = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("acceleration-motion-temperature-contact-battery.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "WISZB-137", + server_clusters = { 0x0003, 0x0005, 0x0006 } + }, + [0x2D] = { + id = 0x2D, + server_clusters = { 0x0000, 0x0001, 0x0003, 0x0020, 0x0500, 0xFC04 } + }, + [0x26] = { + id = 0x26, + server_clusters = { 0x0402 } + } + } + } +) + +local Frient_AccelerationMeasurementCluster = { + ID = 0xFC04, + ManufacturerSpecificCode = 0x1015, + attributes = { + MeasuredValueX = { ID = 0x0000, data_type = data_types.name_to_id_map["Int16"] }, + MeasuredValueY = { ID = 0x0001, data_type = data_types.name_to_id_map["Int16"] }, + MeasuredValueZ = { ID = 0x0002, data_type = data_types.name_to_id_map["Int16"] } + }, +} + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_contact) +end + +test.set_test_init_function(test_init) + +local function custom_configure_reporting(device, cluster, attribute, data_type, min_interval, max_interval, reportable_change, mfg_code) + local message = cluster_base.configure_reporting(device, + data_types.ClusterId(cluster), + data_types.AttributeId(attribute), + data_type, + min_interval, + max_interval, + reportable_change) + + -- Set the manufacturer-specific bit and add the manufacturer code + message.body.zcl_header.frame_ctrl:set_mfg_specific() + message.body.zcl_header.mfg_code = data_types.validate_or_build_type(mfg_code, data_types.Uint16, "mfg_code") + + return message +end + +test.register_coroutine_test( + "init and doConfigure lifecycles should be handled properly", + function() + test.socket.environment_update:__queue_receive({ "zigbee", { hub_zigbee_id = base64.encode(zigbee_test_utils.mock_hub_eui) } }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_device_contact.id, "init" }) + + test.wait_for_events() + + --test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_contact.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device_contact, + 30, + 21600, + 1 + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + TemperatureMeasurement.ID, + TEMPERATURE_ENDPOINT + ):to_endpoint(TEMPERATURE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device_contact, + 0x001E, + 0x012C, + 1 + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device_contact, + 30, + 600, + 100 + ):to_endpoint(TEMPERATURE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.attributes.IASCIEAddress:write( + mock_device_contact, + zigbee_test_utils.mock_hub_eui + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + Frient_AccelerationMeasurementCluster.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + custom_configure_reporting( + mock_device_contact, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.data_type, + 0x0000, + 0x012C, + 0x0001, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + custom_configure_reporting( + mock_device_contact, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.data_type, + 0x0000, + 0x012C, + 0x0001, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + custom_configure_reporting( + mock_device_contact, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.data_type, + 0x0000, + 0x012C, + 0x0001, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device_contact, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ) + }) + + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.attributes.CurrentZoneSensitivityLevel:write( + mock_device_contact, + 0x000A + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting( + mock_device_contact, + 0x001E, + 0x0E10, + 100 + ):to_endpoint(TEMPERATURE_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device_contact, + 0, + 3600, + 0 + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + + test.socket.zigbee:__expect_send({ + mock_device_contact.id, + zigbee_test_utils.build_bind_request( + mock_device_contact, + zigbee_test_utils.mock_hub_eui, + Frient_AccelerationMeasurementCluster.ID, + POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT + ) + }) + + mock_device_contact:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Temperature report should be handled (C) for the temperature measurement cluster", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:build_test_attr_report(mock_device, 2300)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 23.0, unit = "C"})) + }, + { + channel = "devices", + direction = "send", + message = { "register_native_capability_attr_handler", + { + device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" + } + } + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Battery min voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = {mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 23)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Battery max voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = {mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 30)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( +"Refresh necessary attributes", +function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) }) + test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_ENDPOINT) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueX.ID, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueY.ID, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Frient_AccelerationMeasurementCluster.ID, + Frient_AccelerationMeasurementCluster.attributes.MeasuredValueZ.ID, + Frient_AccelerationMeasurementCluster.ManufacturerSpecificCode + ):to_endpoint(POWER_CONFIGURATION_AND_ACCELERATION_ENDPOINT) + }) +end, +{ + min_api_version = 14 +} +) + +test.register_message_test( + "Reported ZoneStatus change should be handled: active motion and inactive acceleration", + { + { + channel = "zigbee", + direction = "receive", + message = {mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active(mock_device)) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive(mock_device)) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Reported ZoneStatus change should be handled: inactive motion and active acceleration", + { + { + channel = "zigbee", + direction = "receive", + message = {mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0002)} + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive(mock_device)) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active(mock_device)) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Three Axis report should be correctly handled", + function() + local attr_report_data = { + { 0x0000, data_types.Int16.ID, 300}, + { 0x0001, data_types.Int16.ID, 200}, + { 0x0002, data_types.Int16.ID, 100}, + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, Frient_AccelerationMeasurementCluster.ID, attr_report_data, 0x1015) + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 200, 100})) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Contact sensor open events should be correctly handled when preference is set", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + local attr_report_data = { + { 0x0000, data_types.Int16.ID, 300}, + { 0x0001, data_types.Int16.ID, 200}, + { 0x0002, data_types.Int16.ID, -902}, + } + test.socket.zigbee:__queue_receive({ + mock_device_contact.id, + zigbee_test_utils.build_attribute_report(mock_device_contact, Frient_AccelerationMeasurementCluster.ID, attr_report_data, 0x1015) + }) + + test.socket.capability:__expect_send( + mock_device_contact:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 200, -902})) + ) + + test.socket.capability:__expect_send( + mock_device_contact:generate_test_message("main", capabilities.contactSensor.contact.open()) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Contact sensor close events should be correctly handled when preference is set", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + local attr_report_data = { + { 0x0000, data_types.Int16.ID, 300}, + { 0x0001, data_types.Int16.ID, 200}, + { 0x0002, data_types.Int16.ID, 100}, + } + test.socket.zigbee:__queue_receive({ + mock_device_contact.id, + zigbee_test_utils.build_attribute_report(mock_device_contact, Frient_AccelerationMeasurementCluster.ID, attr_report_data, 0x1015) + }) + + test.socket.capability:__expect_send( + mock_device_contact:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 200, 100})) + ) + + test.socket.capability:__expect_send( + mock_device_contact:generate_test_message("main", capabilities.contactSensor.contact.closed()) + ) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_multiir_contact_tamper.lua b/drivers/SmartThings/zigbee-contact/src/test/test_multiir_contact_tamper.lua new file mode 100644 index 0000000000..123b9df58c --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/src/test/test_multiir_contact_tamper.lua @@ -0,0 +1,147 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local IASZone = clusters.IASZone + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("contact-battery-tamper-no-fw-update.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "MultIR", + model = "MIR-MC100", + server_clusters = { 0x0001, 0x0500 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.battery.battery(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.contactSensor.contact.closed())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tamperAlert.tamper.clear())) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/closed tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: contact/open tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0005) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: contact/open tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0005, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: contact/closed tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_orvibo_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_orvibo_contact_sensor.lua index 458af19546..9e4bd2aa0a 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_orvibo_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_orvibo_contact_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -53,7 +42,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +93,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_samjin_multi_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_samjin_multi_sensor.lua index d8a23f4af5..7d854c2f6e 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_samjin_multi_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_samjin_multi_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -104,7 +93,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_attribute_read(mock_device, 0xFC02, {0x0010}, 0x1241) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -125,7 +117,10 @@ test.register_coroutine_test( mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -183,7 +178,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -222,7 +220,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_sengled_contact_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_sengled_contact_sensor.lua index 1950a22649..f3e92f2c9a 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_sengled_contact_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_sengled_contact_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -53,7 +42,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +93,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_smartsense_multi.lua b/drivers/SmartThings/zigbee-contact/src/test/test_smartsense_multi.lua index 9db8033062..701aba257b 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_smartsense_multi.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_smartsense_multi.lua @@ -1,21 +1,13 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" + +local IASZone = clusters.IASZone local SMARTSENSE_PROFILE_ID = 0xFC01 local MFG_CODE = 0x110A @@ -122,7 +114,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -137,7 +132,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -152,7 +150,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -167,7 +168,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -182,7 +186,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -197,7 +204,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -212,7 +222,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -227,7 +240,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -242,7 +258,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -257,7 +276,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(97))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -272,7 +294,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -287,7 +312,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(60))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -302,7 +330,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -317,7 +348,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -332,7 +366,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -344,7 +381,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({1050, 3, 9})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -356,7 +396,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({-1050, -3, -9})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -368,7 +411,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({10, 1020, 7})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -380,7 +426,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({-10, -1020, -7})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -392,7 +441,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({116, 4, 1003})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -404,7 +456,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({-116, -4, -1003})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -434,7 +489,86 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({-116, -4, -826})) ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should generate contact event when garageSensor not set: open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should generate contact event when garageSensor not set: closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatus attr report should generate contact event when garageSensor not set: open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatus attr report should generate contact event when garageSensor not set: closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_smartthings_multi_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_smartthings_multi_sensor.lua index 05e5fdd0d4..770b8e6718 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_smartthings_multi_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_smartthings_multi_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -98,6 +87,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -114,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -137,7 +132,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, acceleration_report_inactive, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -151,7 +149,10 @@ test.register_coroutine_test( cluster_base.build_test_read_attr_response(attribute_def, mock_device, 1) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -167,7 +168,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({300, 100, -200})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -200,7 +204,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFC02, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -227,7 +234,48 @@ test.register_coroutine_test( mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0001) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should generate contact event when garageSensor not set: open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should generate contact event when garageSensor not set: closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -251,7 +299,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_attribute_read(mock_device, 0xFC02, {0x0010}, 0x110A) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -272,7 +323,10 @@ test.register_coroutine_test( mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -334,7 +388,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -375,7 +432,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_third_reality_contact.lua b/drivers/SmartThings/zigbee-contact/src/test/test_third_reality_contact.lua index 07beb7c0dd..0273886a4c 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_third_reality_contact.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_third_reality_contact.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -53,7 +42,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +93,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_thirdreality_multi_sensor.lua b/drivers/SmartThings/zigbee-contact/src/test/test_thirdreality_multi_sensor.lua index 24dc80b7b2..924205c3ff 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_thirdreality_multi_sensor.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_thirdreality_multi_sensor.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -49,7 +52,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFFF1, acceleration_report_inactive, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -63,7 +69,10 @@ test.register_coroutine_test( cluster_base.build_test_read_attr_response(attribute_def, mock_device, 1) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -79,7 +88,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, 0xFFF1, attr_report_data, 0x110A) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.threeAxis.threeAxis({200, 100, 300})) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact.lua b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact.lua index d7076ec9ee..1064145d99 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -55,6 +44,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "contactSensor", capability_attr_id = "contact" } } }, + }, + { + min_api_version = 14 } ) @@ -79,6 +71,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "contactSensor", capability_attr_id = "contact" } } }, + }, + { + min_api_version = 14 } ) @@ -105,6 +100,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "contactSensor", capability_attr_id = "contact" } } }, + }, + { + min_api_version = 14 } ) @@ -131,6 +129,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "contactSensor", capability_attr_id = "contact" } } }, + }, + { + min_api_version = 14 } ) @@ -156,6 +157,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -179,6 +183,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -196,6 +203,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -238,7 +248,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -329,7 +342,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_battery.lua b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_battery.lua index 44570bcce1..c7bce911b2 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_battery.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_battery.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -97,7 +86,10 @@ test.register_coroutine_test( test.wait_for_events() end end - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -114,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device_sengled:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -131,6 +126,9 @@ test.register_message_test( direction = "send", message = mock_device_nyce:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_tyco.lua b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_tyco.lua index e288d3a927..625b8f0d6b 100644 --- a/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_tyco.lua +++ b/drivers/SmartThings/zigbee-contact/src/test/test_zigbee_contact_tyco.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local base64 = require "st.base64" @@ -65,7 +54,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } + ) test.register_message_test( "Temperature report should be handled (C)", @@ -89,6 +82,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -107,7 +103,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") test.wait_for_events() - end + end, + { + min_api_version = 15 + } + ) diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/init.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/init.lua index 9e45b9dbad..ed6969cbc8 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/init.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -18,12 +8,12 @@ local defaults = require "st.zigbee.defaults" local zcl_clusters = require "st.zigbee.zcl.clusters" local battery_attribute_configuration = { - cluster = zcl_clusters.PowerConfiguration.ID, - attribute = zcl_clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID, - minimum_interval = 30, - maximum_interval = 14300, -- ~4hrs - data_type = zcl_clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, - reportable_change = 1 + cluster = zcl_clusters.PowerConfiguration.ID, + attribute = zcl_clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID, + minimum_interval = 30, + maximum_interval = 14300, -- ~4hrs + data_type = zcl_clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, + reportable_change = 1 } local function device_init(driver, device) @@ -38,10 +28,11 @@ local zigbee_dimmer_remote_driver_template = { capabilities.switchLevel }, lifecycle_handlers = { - init = device_init, + init = device_init, }, - sub_drivers = { require("zigbee-accessory-dimmer"), require("zigbee-battery-accessory-dimmer")}, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_dimmer_remote_driver_template, zigbee_dimmer_remote_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/sub_drivers.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/sub_drivers.lua new file mode 100644 index 0000000000..c489a2b5d2 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +local sub_drivers = { + lazy_load_if_possible("zigbee-accessory-dimmer"), + lazy_load_if_possible("zigbee-battery-accessory-dimmer"), +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_accessory_dimmer.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_accessory_dimmer.lua index 477b2eac71..75773fdfea 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_accessory_dimmer.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_accessory_dimmer.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2022 SmartThings, Inc. -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -59,6 +59,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -78,6 +81,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -94,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -110,6 +119,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off({ state_change = true })) } + }, + { + min_api_version = 17 } ) @@ -123,7 +135,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, move_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(10))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -136,7 +151,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, move_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(100))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -148,7 +166,10 @@ test.register_coroutine_test( step_command.body.zcl_header.frame_ctrl = frm_ctrl test.socket.zigbee:__queue_receive({ mock_device.id, step_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(90))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -160,7 +181,10 @@ test.register_coroutine_test( step_command.body.zcl_header.frame_ctrl = frm_ctrl test.socket.zigbee:__queue_receive({ mock_device.id, step_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 17 + } ) @@ -174,7 +198,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.held( { state_change = true } ))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -187,7 +214,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -214,7 +244,105 @@ test.register_coroutine_test( Scenes.ID) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "On command when current level is 0 should reset level then toggle switch", + function() + mock_device:set_field("current_level", 0) + test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.server.commands.On.build_test_rx(mock_device) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(10))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Step command(MoveStepMode.DOWN) to level 0 should emit switch off and level 0", + function() + mock_device:set_field("current_level", 10) + local step_command = Level.server.commands.Step.build_test_rx(mock_device, Level.types.MoveStepMode.DOWN, 0x00, + 0x0000, 0x00, 0x00) + local frm_ctrl = FrameCtrl(0x01) + step_command.body.zcl_header.frame_ctrl = frm_ctrl + test.socket.zigbee:__queue_receive({ mock_device.id, step_command }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(0))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Step command(MoveStepMode.UP) when device is off should emit switch on", + function() + mock_device:set_field("current_status", "off") + local step_command = Level.server.commands.Step.build_test_rx(mock_device, Level.types.MoveStepMode.UP, 0x00, + 0x0000, 0x00, 0x00) + local frm_ctrl = FrameCtrl(0x01) + step_command.body.zcl_header.frame_ctrl = frm_ctrl + test.socket.zigbee:__queue_receive({ mock_device.id, step_command }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(100))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability command setLevel should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } } }) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(50))) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Capability command setLevel 0 should restore previous level", + function() + mock_device:set_field("current_level", 30) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 0 } } }) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(30))) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "device added lifecycle should initialize device state", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "held"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_battery_accessory_dimmer.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_battery_accessory_dimmer.lua index b16fe8cd11..b3a1073fe2 100644 Binary files a/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_battery_accessory_dimmer.lua and b/drivers/SmartThings/zigbee-dimmer-remote/src/test/test_zigbee_battery_accessory_dimmer.lua differ diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/can_handle.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/can_handle.lua new file mode 100644 index 0000000000..6e87ef0c4b --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_accessory_dimmer = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-accessory-dimmer.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-accessory-dimmer") + end + end + return false +end + +return is_zigbee_accessory_dimmer diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/fingerprints.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/fingerprints.lua new file mode 100644 index 0000000000..6528171f35 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_ACCESSORY_DIMMER_FINGERPRINTS = { + { mfr = "Aurora", model = "Remote50AU" }, + { mfr = "LDS", model = "ZBT-DIMController-D0800" } +} + +return ZIGBEE_ACCESSORY_DIMMER_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/init.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/init.lua index 91662454c9..b37298802a 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/init.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-accessory-dimmer/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -29,10 +19,6 @@ local CURRENT_LEVEL = "current_level" local CURRENT_STATUS = "current_status" local STEP = 10 -local ZIGBEE_ACCESSORY_DIMMER_FINGERPRINTS = { - { mfr = "Aurora", model = "Remote50AU" }, - { mfr = "LDS", model = "ZBT-DIMController-D0800" } -} local generate_switch_onoff_event = function(device, value) if value == "on" then @@ -142,14 +128,6 @@ local do_configure = function(self, device) end -local is_zigbee_accessory_dimmer = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_ACCESSORY_DIMMER_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local zigbee_accessory_dimmer = { NAME = "zigbee accessory dimmer", @@ -182,7 +160,7 @@ local zigbee_accessory_dimmer = { added = device_added, doConfigure = do_configure }, - can_handle = is_zigbee_accessory_dimmer + can_handle = require("zigbee-accessory-dimmer.can_handle"), } return zigbee_accessory_dimmer diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/can_handle.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/can_handle.lua new file mode 100644 index 0000000000..c3dd2870d4 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_centralite_systems = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-battery-accessory-dimmer.CentraliteSystems.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-battery-accessory-dimmer.CentraliteSystems") + end + end + + return false +end + +return is_centralite_systems diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/fingerprints.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/fingerprints.lua new file mode 100644 index 0000000000..a8d90850b9 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local CENTRALITE_SYSTEMS_FINGERPRINTS = { + { mfr = "Centralite Systems", model = "3131-G" } +} + +return CENTRALITE_SYSTEMS_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/init.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/init.lua index 77bc9fc5a6..a7df73aec5 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/init.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/CentraliteSystems/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local battery_defaults = require "st.zigbee.defaults.battery_defaults" @@ -24,9 +14,6 @@ local capabilities = require "st.capabilities" local DEFAULT_LEVEL = 100 local DOUBLE_STEP = 10 -local CENTRALITE_SYSTEMS_FINGERPRINTS = { - { mfr = "Centralite Systems", model = "3131-G" } -} local generate_switch_level_event = function(device, value) device:emit_event(capabilities.switchLevel.level(value)) @@ -77,15 +64,6 @@ local do_configure = function(self, device) device:send(device_management.build_bind_request(device, Level.ID, self.environment_info.hub_zigbee_eui)) end -local is_centralite_systems = function(opts, driver, device) - for _, fingerprint in ipairs(CENTRALITE_SYSTEMS_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - - return false -end local voltage_configuration = { cluster = zcl_clusters.PowerConfiguration.ID, @@ -118,7 +96,7 @@ local centralite_systems = { init = device_init, doConfigure = do_configure }, - can_handle = is_centralite_systems + can_handle = require("zigbee-battery-accessory-dimmer.CentraliteSystems.can_handle"), } return centralite_systems diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/can_handle.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/can_handle.lua new file mode 100644 index 0000000000..888e889e6d --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_ikea_of_sweden = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-battery-accessory-dimmer.IKEAofSweden.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-battery-accessory-dimmer.IKEAofSweden") + end + end + + return false +end + +return is_ikea_of_sweden diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/fingerprints.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/fingerprints.lua new file mode 100644 index 0000000000..429684c8c7 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IKEA_OF_SWEDEN_FINGERPRINTS = { + { mfr = "IKEA of Sweden", model = "TRADFRI wireless dimmer" } +} + +return IKEA_OF_SWEDEN_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/init.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/init.lua index 8edf50b296..4c87146ea2 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/init.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/IKEAofSweden/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local utils = require 'st.utils' local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -23,9 +13,6 @@ local capabilities = require "st.capabilities" local DEFAULT_LEVEL = 100 local DOUBLE_STEP = 10 -local IKEA_OF_SWEDEN_FINGERPRINTS = { - { mfr = "IKEA of Sweden", model = "TRADFRI wireless dimmer" } -} local generate_switch_level_event = function(device, value) device:emit_event(capabilities.switchLevel.level(value)) @@ -99,15 +86,6 @@ local battery_perc_attr_handler = function(driver, device, value, zb_rx) device:emit_event(capabilities.battery.battery(utils.clamp_value(value.value, 0, 100))) end -local is_ikea_of_sweden = function(opts, driver, device) - for _, fingerprint in ipairs(IKEA_OF_SWEDEN_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - - return false -end local ikea_of_sweden = { NAME = "IKEA of Sweden", @@ -126,7 +104,7 @@ local ikea_of_sweden = { } } }, - can_handle = is_ikea_of_sweden + can_handle = require("zigbee-battery-accessory-dimmer.IKEAofSweden.can_handle"), } return ikea_of_sweden diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/can_handle.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/can_handle.lua new file mode 100644 index 0000000000..7b8f11b278 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_battery_accessory_dimmer = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-battery-accessory-dimmer.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-battery-accessory-dimmer") + end + end + + return false +end + +return is_zigbee_battery_accessory_dimmer diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/fingerprints.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/fingerprints.lua new file mode 100644 index 0000000000..16c4a7f7b4 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_BATTERY_ACCESSORY_DIMMER_FINGERPRINTS = { + { mfr = "sengled", model = "E1E-G7F" }, + { mfr = "IKEA of Sweden", model = "TRADFRI wireless dimmer" }, + { mfr = "Centralite Systems", model = "3131-G" } +} + +return ZIGBEE_BATTERY_ACCESSORY_DIMMER_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/init.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/init.lua index 134931c2c8..01417c1a64 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/init.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local zcl_clusters = require "st.zigbee.zcl.clusters" local OnOff = zcl_clusters.OnOff @@ -22,11 +13,6 @@ local SwitchLevel = capabilities.switchLevel local DEFAULT_LEVEL = 100 local DOUBLE_STEP = 10 -local ZIGBEE_BATTERY_ACCESSORY_DIMMER_FINGERPRINTS = { - { mfr = "sengled", model = "E1E-G7F" }, - { mfr = "IKEA of Sweden", model = "TRADFRI wireless dimmer" }, - { mfr = "Centralite Systems", model = "3131-G" } -} local generate_switch_level_event = function(device, value) device:emit_event(capabilities.switchLevel.level(value)) @@ -89,15 +75,6 @@ local device_added = function(self, device) end end -local is_zigbee_battery_accessory_dimmer = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_BATTERY_ACCESSORY_DIMMER_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - - return false -end local zigbee_battery_accessory_dimmer = { NAME = "zigbee battery accessory dimmer", @@ -121,8 +98,7 @@ local zigbee_battery_accessory_dimmer = { lifecycle_handlers = { added = device_added }, - sub_drivers = { require("zigbee-battery-accessory-dimmer/CentraliteSystems"), require("zigbee-battery-accessory-dimmer/IKEAofSweden"), require("zigbee-battery-accessory-dimmer/sengled") }, - can_handle = is_zigbee_battery_accessory_dimmer + sub_drivers = require("zigbee-battery-accessory-dimmer.sub_drivers"), } return zigbee_battery_accessory_dimmer diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/can_handle.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/can_handle.lua new file mode 100644 index 0000000000..5fa64577ca --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sengled = function(opts, driver, device) + local FINGERPRINTS = require("zigbee-battery-accessory-dimmer.sengled.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("zigbee-battery-accessory-dimmer.sengled") + end + end + + return false +end + +return is_sengled diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/fingerprints.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/fingerprints.lua new file mode 100644 index 0000000000..252c48b6d4 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SENGLED_FINGERPRINTS = { + { mfr = "sengled", model = "E1E-G7F" } +} + +return SENGLED_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/init.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/init.lua index 08d3dfc4a8..7877c41b68 100644 --- a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/init.lua +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sengled/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" @@ -20,9 +10,6 @@ local DOUBLE_STEP = 10 local SENGLED_MFR_SPECIFIC_CLUSTER = 0xFC10 local SENGLED_MFR_SPECIFIC_COMMAND = 0x00 -local SENGLED_FINGERPRINTS = { - { mfr = "sengled", model = "E1E-G7F" } -} local generate_switch_level_event = function(device, value) device:emit_event(capabilities.switchLevel.level(value)) @@ -84,15 +71,6 @@ local sengled_mfr_specific_command_handler = function(driver, device, zb_rx) end end -local is_sengled = function(opts, driver, device) - for _, fingerprint in ipairs(SENGLED_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - - return false -end local sengled = { NAME = "sengled", @@ -103,7 +81,7 @@ local sengled = { } } }, - can_handle = is_sengled + can_handle = require("zigbee-battery-accessory-dimmer.sengled.can_handle"), } return sengled diff --git a/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sub_drivers.lua b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sub_drivers.lua new file mode 100644 index 0000000000..0669f18b25 --- /dev/null +++ b/drivers/SmartThings/zigbee-dimmer-remote/src/zigbee-battery-accessory-dimmer/sub_drivers.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +local sub_drivers = { + lazy_load_if_possible("zigbee-battery-accessory-dimmer.CentraliteSystems"), + lazy_load_if_possible("zigbee-battery-accessory-dimmer.IKEAofSweden"), + lazy_load_if_possible("zigbee-battery-accessory-dimmer.sengled"), +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-fan/src/configurations.lua b/drivers/SmartThings/zigbee-fan/src/configurations.lua index c2cbb57de0..f6bcbe7bf5 100644 --- a/drivers/SmartThings/zigbee-fan/src/configurations.lua +++ b/drivers/SmartThings/zigbee-fan/src/configurations.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" diff --git a/drivers/SmartThings/zigbee-fan/src/fan-light/can_handle.lua b/drivers/SmartThings/zigbee-fan/src/fan-light/can_handle.lua new file mode 100644 index 0000000000..46523f65f9 --- /dev/null +++ b/drivers/SmartThings/zigbee-fan/src/fan-light/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_itm_fanlight(opts, driver, device) + local FINGERPRINTS = require("fan-light.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("fan-light") + end + end + return false +end + +return can_handle_itm_fanlight diff --git a/drivers/SmartThings/zigbee-fan/src/fan-light/fingerprints.lua b/drivers/SmartThings/zigbee-fan/src/fan-light/fingerprints.lua new file mode 100644 index 0000000000..bf93802c10 --- /dev/null +++ b/drivers/SmartThings/zigbee-fan/src/fan-light/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "Samsung Electronics", model = "SAMSUNG-ITM-Z-003" }, +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-fan/src/fan-light/init.lua b/drivers/SmartThings/zigbee-fan/src/fan-light/init.lua index 3ec72ddb8f..341876c676 100644 --- a/drivers/SmartThings/zigbee-fan/src/fan-light/init.lua +++ b/drivers/SmartThings/zigbee-fan/src/fan-light/init.lua @@ -1,34 +1,13 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local FanControl = clusters.FanControl local Level = clusters.Level local OnOff = clusters.OnOff -local FINGERPRINTS = { - { mfr = "Samsung Electronics", model = "SAMSUNG-ITM-Z-003" }, -} -local function can_handle_itm_fanlight(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end -- CAPABILITY HANDLERS @@ -115,9 +94,7 @@ local itm_fan_light = { [capabilities.fanSpeed.commands.setFanSpeed.NAME] = fan_speed_handler } }, - can_handle = can_handle_itm_fanlight + can_handle = require("fan-light.can_handle"), } return itm_fan_light - - diff --git a/drivers/SmartThings/zigbee-fan/src/init.lua b/drivers/SmartThings/zigbee-fan/src/init.lua index a34e90ff5e..33af1cb320 100644 --- a/drivers/SmartThings/zigbee-fan/src/init.lua +++ b/drivers/SmartThings/zigbee-fan/src/init.lua @@ -1,23 +1,13 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" -local configurationMap = require "configurations" local device_init = function(self, device) + local configurationMap = require "configurations" local configuration = configurationMap.get_device_configuration(device) if configuration ~= nil then for _, attribute in ipairs(configuration) do @@ -32,16 +22,14 @@ local zigbee_fan_driver = { capabilities.switchLevel, capabilities.fanspeed }, - sub_drivers = { - require("fan-light") - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { init = device_init }, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_fan_driver,zigbee_fan_driver.supported_capabilities) local fan = ZigbeeDriver("zigbee-fan", zigbee_fan_driver) fan:run() - diff --git a/drivers/SmartThings/zigbee-fan/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-fan/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-fan/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-fan/src/sub_drivers.lua b/drivers/SmartThings/zigbee-fan/src/sub_drivers.lua new file mode 100644 index 0000000000..f43392d622 --- /dev/null +++ b/drivers/SmartThings/zigbee-fan/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("fan-light"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-fan/src/test/test_fan_light.lua b/drivers/SmartThings/zigbee-fan/src/test/test_fan_light.lua index 4ef4ce37e6..fbee4cf910 100644 --- a/drivers/SmartThings/zigbee-fan/src/test/test_fan_light.lua +++ b/drivers/SmartThings/zigbee-fan/src/test/test_fan_light.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" local clusters = require "st.zigbee.zcl.clusters" @@ -58,6 +48,9 @@ test.register_message_test( message = { mock_base_device.id, Level.server.commands.MoveToLevelWithOnOff (mock_base_device, 254, 0) } } + }, + { + min_api_version = 14 } ) @@ -76,6 +69,9 @@ test.register_message_test( message = { mock_base_device.id, Level.server.commands.MoveToLevelWithOnOff (mock_base_device, 127, 0) } } + }, + { + min_api_version = 14 } ) @@ -94,6 +90,9 @@ test.register_message_test( message = { mock_base_device.id, Level.server.commands.MoveToLevelWithOnOff (mock_base_device, 127, 0) } } + }, + { + min_api_version = 14 } ) @@ -112,6 +111,9 @@ test.register_message_test( message = { mock_base_device.id, Level.server.commands.MoveToLevelWithOnOff (mock_base_device, 0, 0) } } + }, + { + min_api_version = 14 } ) @@ -134,6 +136,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 14 } ) @@ -156,6 +161,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 14 } ) @@ -173,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -190,6 +201,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -207,6 +221,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switchLevel.level(100)) } + }, + { + min_api_version = 14 } ) @@ -224,6 +241,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switchLevel.level(50)) } + }, + { + min_api_version = 14 } ) @@ -241,6 +261,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("light", capabilities.switchLevel.level(0)) } + }, + { + min_api_version = 14 } ) @@ -262,6 +285,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 14 } ) @@ -283,6 +309,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 14 } ) @@ -304,6 +333,9 @@ test.register_message_test( direction = "send", message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } } + }, + { + min_api_version = 14 } ) @@ -331,6 +363,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(1)) } + }, + { + min_api_version = 14 } ) @@ -358,6 +393,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(2)) } + }, + { + min_api_version = 14 } ) @@ -385,6 +423,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(3)) } + }, + { + min_api_version = 14 } ) @@ -412,6 +453,9 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(4)) } + }, + { + min_api_version = 14 } ) @@ -439,7 +483,58 @@ test.register_message_test( direction = "send", message = mock_base_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(0)) } + }, + { + min_api_version = 14 } ) +test.register_message_test( + "Fan switch on command from main component", + { + { + channel = "capability", + direction = "receive", + message = { mock_base_device.id, { capability = "switch", component = "main", command = "on", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_base_device.id, FanControl.attributes.FanMode:write(mock_base_device, 1) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Fan switch off command from main component", + { + { + channel = "capability", + direction = "receive", + message = { mock_base_device.id, { capability = "switch", component = "main", command = "off", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_base_device.id, FanControl.attributes.FanMode:write(mock_base_device, 0x00) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_base_device.id, FanControl.attributes.FanMode:read(mock_base_device) } + } + }, + { + min_api_version = 14 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml index 68359710fe..ee36659670 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml @@ -79,10 +79,20 @@ zigbeeManufacturer: model: TH01 deviceProfileName: humidity-temp-battery - id: eWeLink/SNZB-02P - deviceLabel: eWeLink Multipurpose Sensor + deviceLabel: SONOFF Zigbee Temperature and Humidity Sensor manufacturer: eWeLink model: SNZB-02P deviceProfileName: humidity-temp-battery + - id: "SONOFF/SNZB-02DR2" + deviceLabel: "SONOFF SNZB-02DR2" + manufacturer: SONOFF + model: SNZB-02DR2 + deviceProfileName: humidity-temp-battery + - id: "SONOFF/SNZB-02B" + deviceLabel: "SONOFF SNZB-02B" + manufacturer: SONOFF + model: SNZB-02B + deviceProfileName: humidity-temp-battery - id: "Third Reality/3RTHS24BZ" deviceLabel: ThirdReality Temperature and Humidity Sensor manufacturer: Third Reality, Inc diff --git a/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml b/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml index 08774c0d31..1df453e4aa 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/profiles/frient-airquality-humidity-temperature-battery.yml @@ -27,6 +27,8 @@ components: version: 1 - id: refresh version: 1 + categories: + - name: AirQualityDetector preferences: - preferenceId: humidityOffset explicit: true diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua index 8bc35c8765..043359768f 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua @@ -56,7 +56,8 @@ local devices = { EWELINK_HUMIDITY_TEMP_SENSOR = { FINGERPRINTS = { { mfr = "eWeLink", model = "TH01" }, - { mfr = "eWeLink", model = "SNZB-02P" } + { mfr = "eWeLink", model = "SNZB-02P" }, + { mfr = "SONOFF", model = "SNZB-02DR2" } }, CONFIGURATION = { { diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua index 9ca7cd734d..6bde23ed25 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/init.lua @@ -70,6 +70,7 @@ local zigbee_humidity_driver = { }, sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_humidity_driver, zigbee_humidity_driver.supported_capabilities, {native_capability_attrs_enabled = true}) diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_aqara_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_aqara_sensor.lua index 497395ac55..87f8c0e85c 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_aqara_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_aqara_sensor.lua @@ -18,11 +18,17 @@ local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" local PowerConfiguration = clusters.PowerConfiguration local TemperatureMeasurement = clusters.TemperatureMeasurement local RelativeHumidity = clusters.RelativeHumidity +local PRIVATE_CLUSTER_ID = 0xFCC0 +local PRIVATE_ATTRIBUTE_ID = 0x0009 +local MFG_CODE = 0x115F + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("humidity-temp-battery-aqara.yml"), @@ -88,7 +94,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryVoltage:configure_reporting(mock_device, 30, 3600, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -108,6 +117,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 14 } ) @@ -128,6 +140,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 0 })) } + }, + { + min_api_version = 14 } ) @@ -148,6 +163,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 100 })) } + }, + { + min_api_version = 14 } ) @@ -176,6 +194,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -196,7 +217,10 @@ test.register_coroutine_test( capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -215,6 +239,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.normal()) } + }, + { + min_api_version = 14 } ) @@ -234,6 +261,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.critical()) } + }, + { + min_api_version = 14 } ) @@ -253,6 +283,31 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.batteryLevel.battery.warning()) } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Added handler should send manufacturer attribute and initialize device state", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.temperatureMeasurement.temperature({ value = 0, unit = "C" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.relativeHumidityMeasurement.humidity(0))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.batteryLevel.battery("normal"))) + test.wait_for_events() + end, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_centralite_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_centralite_sensor.lua index c9b469376c..6fdf421c04 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_centralite_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_centralite_sensor.lua @@ -94,7 +94,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -183,7 +184,10 @@ test.register_coroutine_test( ) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) local function build_test_attr_report(device, value) @@ -225,6 +229,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 75 })) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_ewelink_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_ewelink_sensor.lua index 35f557e850..c8d5578e11 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_ewelink_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_ewelink_sensor.lua @@ -65,7 +65,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -111,7 +112,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua index e985dafce5..eb022d7ebf 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_air_quality_sensor.lua @@ -84,7 +84,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -101,6 +102,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -117,12 +121,16 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") @@ -191,7 +199,30 @@ test.register_coroutine_test( mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + test.wait_for_events() + + --refresh happens after configure + test.mock_time.advance_time(5) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, Frient_VOCMeasurement.ID, Frient_VOCMeasurement.attributes.MeasuredValue.ID, Frient_VOCMeasurement.ManufacturerSpecificCode):to_endpoint(0x26) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(0x26) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(0x26) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + }) + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -210,6 +241,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 65 })) } + }, + { + min_api_version = 14 } ) @@ -226,6 +260,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -259,7 +296,10 @@ test.register_coroutine_test( ) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -287,7 +327,25 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Added handler should initialize VOC and air quality state", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.airQualitySensor.airQuality({ value = 0 }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocHealthConcern.tvocHealthConcern({ value = "good" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tvocMeasurement.tvocLevel({ value = 0, unit = "ppb" }))) + test.wait_for_events() + end, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_sensor.lua index 5b9ef77634..dfca8c6be1 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_frient_sensor.lua @@ -66,7 +66,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -83,6 +84,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -99,12 +103,16 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) test.register_coroutine_test( "Configure should configure all necessary attributes", function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.zigbee:__expect_send({ @@ -141,7 +149,26 @@ test.register_coroutine_test( TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 0x001E, 0x0E10, 100) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + test.wait_for_events() + + test.mock_time.advance_time(5) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -160,6 +187,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 65 })) } + }, + { + min_api_version = 14 } ) @@ -184,12 +214,16 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) test.register_coroutine_test( "info_changed to check for necessary preferences settings: Temperature Sensitivity", function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") local updates = { preferences = { temperatureSensitivity = 0.9, @@ -217,7 +251,25 @@ test.register_coroutine_test( ) }) test.wait_for_events() - end + + test.mock_time.advance_time(5) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + HumidityMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_heiman_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_heiman_sensor.lua index 773bd3e9e4..41a9d5ba22 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_heiman_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_heiman_sensor.lua @@ -65,7 +65,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -122,7 +123,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_battery_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_battery_sensor.lua index f914f0a898..d23abc3777 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_battery_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_battery_sensor.lua @@ -46,6 +46,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 50.0 })) } + }, + { + min_api_version = 14 } ) @@ -65,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 0.0 })) } + }, + { + min_api_version = 14 } ) @@ -84,6 +90,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 100.0 })) } + }, + { + min_api_version = 14 } ) @@ -108,7 +117,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_plaid_systems.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_plaid_systems.lua index 6bca6c4e1a..69a154e13e 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_plaid_systems.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_plaid_systems.lua @@ -49,6 +49,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 14 } ) @@ -68,6 +71,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 0 })) } + }, + { + min_api_version = 14 } ) @@ -87,6 +93,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 100 })) } + }, + { + min_api_version = 14 } ) @@ -114,6 +123,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -137,7 +149,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc))) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -155,7 +170,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -190,6 +208,9 @@ test.register_message_test( TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) } }, + }, + { + min_api_version = 14 } ) @@ -227,6 +248,9 @@ test.register_message_test( TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) } }, + }, + { + min_api_version = 14 } ) @@ -261,7 +285,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature.lua index 6de1dceb18..dc3e6052c5 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature.lua @@ -46,6 +46,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -73,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -92,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 14 } ) @@ -126,7 +135,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -157,7 +169,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 }))) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_battery.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_battery.lua index 341cd4bc20..6ffd1b5f28 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_battery.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_battery.lua @@ -58,6 +58,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -85,6 +88,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -104,6 +110,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 14 } ) @@ -146,7 +155,10 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -177,7 +189,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 }))) - end + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_sensor.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_sensor.lua index 4282db0ffd..06a18c6063 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_sensor.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/test/test_humidity_temperature_sensor.lua @@ -53,6 +53,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -80,6 +83,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -99,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 14 } ) @@ -133,7 +142,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..69218a197a --- /dev/null +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.sen_ill.agl01" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/init.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/init.lua index 72fcbc2489..0eb5e1b059 100644 --- a/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local zcl_commands = require "st.zigbee.zcl.global_commands" @@ -19,9 +22,6 @@ local FREQUENCY_ATTRIBUTE_ID = 0x0000 local FREQUENCY_DEFAULT_VALUE = 5 local FREQUENCY_PREF = "frequencyPref" -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.sen_ill.agl01" } -} local configuration = { { @@ -42,14 +42,6 @@ local configuration = { } } -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function detection_frequency_capability_handler(driver, device, command) local frequency = command.args.frequency @@ -104,7 +96,7 @@ local aqara_illuminance_handler = { } } }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_illuminance_handler diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/init.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/init.lua index 45a3ade62b..6f9d3c5554 100644 --- a/drivers/SmartThings/zigbee-illuminance-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/init.lua @@ -1,30 +1,27 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" +local do_configure = function(self, device) + device:configure() + device:refresh() +end + local zigbee_illuminance_driver = { supported_capabilities = { capabilities.illuminanceMeasurement, capabilities.battery }, - sub_drivers = { - require("aqara") + lifecycle_handlers = { + doConfigure = do_configure, }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_illuminance_driver, zigbee_illuminance_driver.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/sub_drivers.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..6211f46578 --- /dev/null +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor.lua index 02c3c63a55..f8c959141d 100644 --- a/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor.lua +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -55,6 +44,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 14 } ) @@ -74,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -107,7 +102,12 @@ test.register_coroutine_test( zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) } ) - end + test.socket.zigbee:__expect_send({ mock_device.id, IlluminanceMeasurement.attributes.MeasuredValue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor_aqara.lua b/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor_aqara.lua index 11d66478ba..8c519b17ca 100644 --- a/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor_aqara.lua +++ b/drivers/SmartThings/zigbee-illuminance-sensor/src/test/test_illuminance_sensor_aqara.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -20,6 +9,10 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local write_attribute_response = require "st.zigbee.zcl.global_commands.write_attribute_response" +local zcl_messages = require "st.zigbee.zcl" test.add_package_capability("detectionFrequency.yaml") local IlluminanceMeasurement = clusters.IlluminanceMeasurement @@ -30,8 +23,10 @@ local detectionFrequency = capabilities["stse.detectionFrequency"] local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local MFG_CODE = 0x115F +local FREQUENCY_ATTRIBUTE_ID = 0x0000 local FREQUENCY_DEFAULT_VALUE = 5 +local FREQUENCY_PREF = "frequencyPref" local mock_device = test.mock_device.build_test_zigbee_device( { @@ -62,7 +57,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", detectionFrequency.detectionFrequency(FREQUENCY_DEFAULT_VALUE, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -100,7 +98,10 @@ test.register_coroutine_test( , data_types.Uint8, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -120,6 +121,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 14 } ) @@ -139,6 +143,61 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 + } +) + +local function build_write_attr_res(cluster, status) + local addr_header = messages.AddressHeader( + mock_device:get_short_address(), + mock_device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local write_attribute_body = write_attribute_response.WriteAttributeResponse(status, {}) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(write_attribute_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = write_attribute_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + +test.register_coroutine_test( + "Handle setDetectionFrequency capability command", + function() + local frequency = 10 + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.detectionFrequency", component = "main", command = "setDetectionFrequency", args = { frequency } } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, FREQUENCY_ATTRIBUTE_ID, + MFG_CODE, data_types.Uint16, frequency) }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Handle write attr res on PRIVATE_CLUSTER_ID emits detectionFrequency", + function() + mock_device:set_field(FREQUENCY_PREF, FREQUENCY_DEFAULT_VALUE) + test.socket.zigbee:__queue_receive({ mock_device.id, + build_write_attr_res(PRIVATE_CLUSTER_ID, 0x00) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + detectionFrequency.detectionFrequency(FREQUENCY_DEFAULT_VALUE, { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-lock/fingerprints.yml b/drivers/SmartThings/zigbee-lock/fingerprints.yml index 86870aa962..309a0f2883 100644 --- a/drivers/SmartThings/zigbee-lock/fingerprints.yml +++ b/drivers/SmartThings/zigbee-lock/fingerprints.yml @@ -1,4 +1,35 @@ zigbeeManufacturer: + # GELUBU + - id: "GELUBU/S93" + deviceLabel: GELUBU Door Lock S93 + manufacturer: GELUBU + model: S93 + deviceProfileName: lock-battery + - id: "GELUBU/G30" + deviceLabel: GELUBU Door Lock G30 + manufacturer: GELUBU + model: G30 + deviceProfileName: lock-battery + - id: "GELUBU/R60" + deviceLabel: GELUBU Door Lock R60 + manufacturer: GELUBU + model: R60 + deviceProfileName: lock-battery + - id: "GELUBU/R50" + deviceLabel: GELUBU Door Lock R50 + manufacturer: GELUBU + model: R50 + deviceProfileName: lock-battery + - id: "GELUBU/P90" + deviceLabel: GELUBU Door Lock P90 + manufacturer: GELUBU + model: P90 + deviceProfileName: lock-battery + - id: "GELUBU/R71" + deviceLabel: GELUBU Door Lock R71 + manufacturer: GELUBU + model: R71 + deviceProfileName: lock-battery # YALE - id: "Yale YRD220/240" deviceLabel: "Yale Door Lock" diff --git a/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml b/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml index 159e6939f3..c9c98898c6 100644 --- a/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/zigbee-lock/profiles/base-lock.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua new file mode 100644 index 0000000000..7d7d566118 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local fingerprints = require("bad-battery-reporter.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("bad-battery-reporter") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua new file mode 100644 index 0000000000..3024aeeace --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local BAD_BATTERY_REPORTING_LOCK_FINGERPRINTS = { + { mfr = "Yale", model = "YRD220/240 TSDB" }, + { mfr = "Yale", model = "YRL220 TS LL" }, + { mfr = "Yale", model = "YRD210 PB DB" }, + { mfr = "Yale", model = "YRL210 PB LL" }, + { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, + { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } +} + +return BAD_BATTERY_REPORTING_LOCK_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua new file mode 100644 index 0000000000..5fbdabd1b7 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/bad-battery-reporter/init.lua @@ -0,0 +1,26 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + + + +local battery_report_handler = function(driver, device, value) + device:emit_event(capabilities.battery.battery(value.value)) +end + +local bad_battery_reporter_driver = { + NAME = "Bad Battery Reporter Driver", + zigbee_handlers = { + attr = { + [clusters.PowerConfiguration.ID] = { + [clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_report_handler + } + } + }, + can_handle = require("bad-battery-reporter.can_handle") +} + +return bad_battery_reporter_driver diff --git a/drivers/SmartThings/zigbee-lock/src/configurations.lua b/drivers/SmartThings/zigbee-lock/src/configurations.lua index a2429252b0..88e4e59f80 100644 --- a/drivers/SmartThings/zigbee-lock/src/configurations.lua +++ b/drivers/SmartThings/zigbee-lock/src/configurations.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" diff --git a/drivers/SmartThings/zigbee-lock/src/init.lua b/drivers/SmartThings/zigbee-lock/src/init.lua index ce6894b868..b8b290af4d 100644 --- a/drivers/SmartThings/zigbee-lock/src/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/init.lua @@ -1,464 +1,159 @@ --- Copyright 2022 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. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- Zigbee Driver utilities +local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" local device_management = require "st.zigbee.device_management" -local ZigbeeDriver = require "st.zigbee" - --- Zigbee Spec Utils -local clusters = require "st.zigbee.zcl.clusters" -local Alarm = clusters.Alarms -local LockCluster = clusters.DoorLock -local PowerConfiguration = clusters.PowerConfiguration - --- Capabilities -local capabilities = require "st.capabilities" -local Battery = capabilities.battery -local Lock = capabilities.lock -local LockCodes = capabilities.lockCodes - --- Enums -local UserStatusEnum = LockCluster.types.DrlkUserStatus -local UserTypeEnum = LockCluster.types.DrlkUserType -local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode - -local socket = require "cosock.socket" -local lock_utils = require "lock_utils" - -local DELAY_LOCK_EVENT = "_delay_lock_event" -local MAX_DELAY = 10 - -local reload_all_codes = function(driver, device, command) - -- starts at first user code index then iterates through all lock codes as they come in - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) == nil) then - device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) == nil) then - device:send(LockCluster.attributes.MinPINCodeLength:read(device)) - end - if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) == nil) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) - end - if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 0) end - device:emit_event(LockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) - device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) -end - -local refresh = function(driver, device, cmd) - device:refresh() - device:send(LockCluster.attributes.LockState:read(device)) - device:send(Alarm.attributes.AlarmCount:read(device)) - -- we can't determine from fingerprints if devices support lock codes, so - -- here in the driver we'll do a check once to see if the device responds here - -- and if it does, we'll switch it to a profile with lock codes - if not device:supports_capability_by_id(LockCodes.ID) and not device:get_field(lock_utils.CHECKED_CODE_SUPPORT) then - device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) - -- we won't make this value persist because it's not that important - device:set_field(lock_utils.CHECKED_CODE_SUPPORT, true) - end -end - -local do_configure = function(self, device) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - - device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) - device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) - - device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) - device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) - - -- Don't send a reload all codes if this is a part of migration - if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then - device.thread:call_with_delay(2, function(d) - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end) - else - device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local table_utils = require "lock_utils.tables" +local zigbee_handlers = require "lock_handlers.zigbee_responses" +local capability_handlers = require "lock_handlers.capabilities" + +local LockLifecycle = {} + +function LockLifecycle.device_added(driver, device) + if device:supports_capability(capabilities.lockCodes) and device._provisioning_state == "TYPED" then + -- set the migrated field to true so new devices use lockCredentials/lockUsers from the start. + -- auto-migration is only run for typed devices, as provisioned devices have already been onboarded, + -- and should be migrated manually by the user. + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migration event in the datastore end + -- set initial state + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) end -local alarm_handler = function(driver, device, zb_mess) - local ALARM_REPORT = { - [0] = Lock.lock.unknown(), - [1] = Lock.lock.unknown(), - -- Events 16-19 are low battery events, but are presented as descriptionText only - } - if (ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value] ~= nil) then - device:emit_event(ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value]) +function LockLifecycle.init(driver, device) + -- Restore users/credentials capability state from the persistent store in case + -- the capability state cache was wiped since the last driver run. + table_utils.restore_from_persistent_store(device) + + local lock_pins_supported_by_profile = device:supports_capability(capabilities.lockCodes) + if lock_pins_supported_by_profile and device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) == true then + -- ensure lockCodes capability state is reflected correctly for already migrated devices + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + elseif not lock_pins_supported_by_profile then + -- generically fingerprinted profiles do not have any codes/users/credentials capabilities. + -- We should check its PIN users if it should be re-profiled. + device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) end end -local get_pin_response_handler = function(driver, device, zb_mess) - local event = LockCodes.codeChanged("", { state_change = true }) - local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = {codeName = lock_utils.get_code_name(device, code_slot)} - if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then - -- Code slot is occupied - event.value = code_slot .. lock_utils.get_change_type(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = event.data.codeName - device:emit_event(event) - lock_utils.lock_codes_event(device, lock_codes) - lock_utils.reset_code_state(device, code_slot) - else - -- Code slot is unoccupied - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - -- Code has been deleted - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - else - -- Code is unset - event.value = code_slot .. " unset" - device:emit_event(event) - end - end +function LockLifecycle.do_configure(driver, device) + device:send(device_management.build_bind_request(device, clusters.PowerConfiguration.ID, driver.environment_info.hub_zigbee_eui)) + device:send(clusters.PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) - code_slot = tonumber(code_slot) - if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then - -- the code we're checking has arrived - local last_slot = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) - 1 - if (code_slot >= last_slot) then - device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) - device:set_field(lock_utils.CHECKING_CODE, nil) - else - local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 - device:set_field(lock_utils.CHECKING_CODE, checkingCode) - device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) - end - end -end + device:send(device_management.build_bind_request(device, clusters.DoorLock.ID, driver.environment_info.hub_zigbee_eui)) + device:send(clusters.DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0)) -local programming_event_handler = function(driver, device, zb_mess) - local event = LockCodes.codeChanged("", { state_change = true }) - local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) - event.data = {} - if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then - -- Master code changed - event.value = "0 set" - event.data = {codeName = "Master Code"} - device:emit_event(event) - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then - if (zb_mess.body.zcl_body.user_id.value == 0xFF) then - -- All codes deleted - for cs, _ in pairs(lock_utils.get_lock_codes(device)) do - lock_utils.code_deleted(device, cs) - end - lock_utils.lock_codes_event(device, {}) - else - -- One code deleted - if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then - lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) - end - end - elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or - zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then - -- Code added or changed - local change_type = lock_utils.get_change_type(device, code_slot) - local code_name = lock_utils.get_code_name(device, code_slot) - event.value = code_slot .. change_type - event.data = {codeName = code_name} - device:emit_event(event) - if (change_type == " set") then - local lock_codes = lock_utils.get_lock_codes(device) - lock_codes[code_slot] = code_name - lock_utils.lock_codes_event(device, lock_codes) - end - end -end + device:send(device_management.build_bind_request(device, clusters.Alarms.ID, driver.environment_info.hub_zigbee_eui)) + device:send(clusters.Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) -local handle_max_codes = function(driver, device, value) - if value.value ~= 0 then - -- Here's where we'll end up if we queried a lock whose profile does not have lock codes, - -- but it gave us a non-zero number of pin users, so we want to switch the profile - if not device:supports_capability_by_id(LockCodes.ID) then - device:try_update_metadata({profile = "base-lock"}) -- switch to a lock with codes - lock_utils.populate_state_from_data(device) -- if this was a migrated device, try to migrate the lock codes - if not device:get_field(lock_utils.MIGRATION_COMPLETE) then -- this means we didn't find any pre-migration lock codes - -- so we'll load them manually - driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - }) - end - end - device:emit_event(LockCodes.maxCodes(value.value, { visibility = { displayed = false } })) + if device:supports_capability(capabilities.lockCredentials) then + -- ensure our user/credential state is accurate to the current device state + device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) end end -local handle_max_code_length = function(driver, device, value) - device:emit_event(LockCodes.maxCodeLength(value.value, { visibility = { displayed = false } })) -end - -local handle_min_code_length = function(driver, device, value) - device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) -end - -local update_codes = function(driver, device, command) - local delay = 0 - -- args.codes is json - for name, code in pairs(command.args.codes) do - -- these seem to come in the format "code[slot#]: code" - local code_slot = tonumber(string.gsub(name, "code", ""), 10) - if (code_slot ~= nil) then - if (code ~= nil and (code ~= "0" and code ~= "")) then - device.thread:call_with_delay(delay, function () - device:send(LockCluster.server.commands.SetPINCode(device, - code_slot, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - code)) - end) - delay = delay + 2 - else - device.thread:call_with_delay(delay, function () - device:send(LockCluster.server.commands.ClearPINCode(device, code_slot)) - end) - delay = delay + 2 - end - device.thread:call_with_delay(delay, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, code_slot)) - end) - delay = delay + 2 +function LockLifecycle.info_changed(driver, device, event, args) + local profile_switched = device.profile.id ~= args.old_st_store.profile.id + if profile_switched and device:supports_capability(capabilities.lockCodes) then + -- ensure all slga migration steps are run, and that the latest device state is synced to the driver. + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) end - end -end - -local delete_code = function(driver, device, command) - device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) - device:send(LockCluster.server.commands.ClearPINCode(device, command.args.codeSlot)) - device.thread:call_with_delay(2, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) - end) -end - -local request_code = function(driver, device, command) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) -end - -local set_code = function(driver, device, command) - if (command.args.codePIN == "") then + -- ensure all requisite initial state is set driver:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {command.args.codeSlot, command.args.codeName} + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} }) - else - device:send(LockCluster.server.commands.SetPINCode(device, - command.args.codeSlot, - UserStatusEnum.OCCUPIED_ENABLED, - UserTypeEnum.UNRESTRICTED, - command.args.codePIN) - ) - if (command.args.codeName ~= nil) then - -- wait for confirmation from the lock to commit this to memory - -- Groovy driver has a lot more info passed here as a description string, may need to be investigated - local codeState = device:get_field(lock_utils.CODE_STATE) or {} - codeState["setName"..command.args.codeSlot] = command.args.codeName - device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) - end - - device.thread:call_with_delay(4, function(d) - device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) - end) - end -end - -local name_slot = function(driver, device, command) - local code_slot = tostring(command.args.codeSlot) - local lock_codes = lock_utils.get_lock_codes(device) - if (lock_codes[code_slot] ~= nil) then - lock_codes[code_slot] = command.args.codeName - device:emit_event(LockCodes.codeChanged(code_slot .. " renamed", { state_change = true })) - lock_utils.lock_codes_event(device, lock_codes) - end -end - -local function device_added(driver, device) - lock_utils.populate_state_from_data(device) - - driver:inject_capability_command(device, { - capability = capabilities.refresh.ID, - command = capabilities.refresh.commands.refresh.NAME, - args = {} - }) -end - -local function init(driver, device) - lock_utils.populate_state_from_data(device) - -- temp fix before this can be changed to non-persistent - device:set_field(lock_utils.CODE_STATE, nil, { persist = true }) -end - --- The following two functions are from the lock defaults. They are in the base driver temporarily --- until the fix is widely released in the lua libs -local lock_state_handler = function(driver, device, value, zb_rx) - local attr = capabilities.lock.lock - local LOCK_STATE = { - [value.NOT_FULLY_LOCKED] = attr.unknown(), - [value.LOCKED] = attr.locked(), - [value.UNLOCKED] = attr.unlocked(), - [value.UNDEFINED] = attr.unknown(), - } - - -- this is where we decide whether or not we need to delay our lock event because we've - -- observed it coming before the event (or we're starting to compute the timer) - local delay = device:get_field(DELAY_LOCK_EVENT) or 100 - if (delay < MAX_DELAY) then - device.thread:call_with_delay(delay+.5, function () - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) - end) - else - device:set_field(DELAY_LOCK_EVENT, socket.gettime()) - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + -- ensure our user/credential state is accurate to the current device state + device.thread:call_with_delay(15, function() lock_utils.sync_device_state(device) end) end end -local lock_operation_event_handler = function(driver, device, zb_rx) - local event_code = zb_rx.body.zcl_body.operation_event_code.value - local source = zb_rx.body.zcl_body.operation_event_source.value - local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" - local METHOD = { - [0] = "keypad", - [1] = "command", - [2] = "manual", - [3] = "rfid", - [4] = "fingerprint", - [5] = "bluetooth" - } - local STATUS = { - [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), - [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), - [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() - } - local event = STATUS[event_code] - if (event ~= nil) then - event["data"] = {} - if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or - event_code == OperationEventCode.SCHEDULE_LOCK or - event_code == OperationEventCode.SCHEDULE_UNLOCK - ) then - event.data.method = "auto" - else - event.data.method = METHOD[source] - end - if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad - local code_id = zb_rx.body.zcl_body.user_id.value - local code_name = "Code "..code_id - local lock_codes = device:get_field("lockCodes") - if (lock_codes ~= nil and - lock_codes[code_id] ~= nil) then - code_name = lock_codes[code_id] - end - event.data = {method = METHOD[0], codeId = code_id .. "", codeName = code_name} - end - - -- if this is an event corresponding to a recently-received attribute report, we - -- want to set our delay timer for future lock attribute report events - if device:get_latest_state( - device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), - capabilities.lock.ID, - capabilities.lock.lock.ID) == event.value.value then - local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 - local time_diff = socket.gettime() - preceding_event_time - if time_diff < MAX_DELAY then - device:set_field(DELAY_LOCK_EVENT, time_diff) - end - end - - device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) +function LockLifecycle.driver_switched(driver, device) + if device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME) == true then + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) end -end - -local function lock(driver, device, command) - device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) -end - -local function unlock(driver, device, command) - device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) + device:try_update_metadata({ provisioning_state = "PROVISIONED" }) end local zigbee_lock_driver = { - supported_capabilities = { - Lock, - LockCodes, - Battery, + lifecycle_handlers = { + added = LockLifecycle.device_added, + init = LockLifecycle.init, + doConfigure = LockLifecycle.do_configure, + driver_switched = LockLifecycle.driver_switched, + infoChanged = LockLifecycle.info_changed, }, zigbee_handlers = { cluster = { - [Alarm.ID] = { - [Alarm.client.commands.Alarm.ID] = alarm_handler + [clusters.Alarms.ID] = { + [clusters.Alarms.client.commands.Alarm.ID] = zigbee_handlers.alarm }, - [LockCluster.ID] = { - [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, - [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, - [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler + [clusters.DoorLock.ID] = { + [clusters.DoorLock.client.commands.ClearAllPINCodesResponse.ID] = zigbee_handlers.clear_all_pin_codes_response, + [clusters.DoorLock.client.commands.ClearPINCodeResponse.ID] = zigbee_handlers.clear_pin_code_response, + [clusters.DoorLock.client.commands.GetPINCodeResponse.ID] = zigbee_handlers.get_pin_code_response, + [clusters.DoorLock.client.commands.ProgrammingEventNotification.ID] = zigbee_handlers.programming_event_notification, + [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = zigbee_handlers.operating_event_notification, + [clusters.DoorLock.client.commands.SetPINCodeResponse.ID] = zigbee_handlers.set_pin_code_response, } }, attr = { - [LockCluster.ID] = { - [LockCluster.attributes.LockState.ID] = lock_state_handler, - [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, - [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, - [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes + [clusters.DoorLock.ID] = { + [clusters.DoorLock.attributes.LockState.ID] = zigbee_handlers.lock_state, + [clusters.DoorLock.attributes.MaxPINCodeLength.ID] = zigbee_handlers.max_pin_code_length, + [clusters.DoorLock.attributes.MinPINCodeLength.ID] = zigbee_handlers.min_pin_code_length, + [clusters.DoorLock.attributes.NumberOfPINUsersSupported.ID] = zigbee_handlers.number_of_pin_users_supported, } } }, capability_handlers = { - [LockCodes.ID] = { - [LockCodes.commands.updateCodes.NAME] = update_codes, - [LockCodes.commands.deleteCode.NAME] = delete_code, - [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, - [LockCodes.commands.requestCode.NAME] = request_code, - [LockCodes.commands.setCode.NAME] = set_code, - [LockCodes.commands.nameSlot.NAME] = name_slot, + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = capability_handlers.lock, + [capabilities.lock.commands.unlock.NAME] = capability_handlers.unlock, + }, + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = capability_handlers.add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = capability_handlers.update_user, + [capabilities.lockUsers.commands.deleteUser.NAME] = capability_handlers.delete_user, + [capabilities.lockUsers.commands.deleteAllUsers.NAME] = capability_handlers.delete_all_users, }, - [Lock.ID] = { - [Lock.commands.lock.NAME] = lock, - [Lock.commands.unlock.NAME] = unlock, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = capability_handlers.add_credential, + [capabilities.lockCredentials.commands.updateCredential.NAME] = capability_handlers.update_credential, + [capabilities.lockCredentials.commands.deleteCredential.NAME] = capability_handlers.delete_credential, + [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = capability_handlers.delete_all_credentials, }, [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = refresh - } - }, - sub_drivers = { - require("samsungsds"), - require("yale"), - require("yale-fingerprint-lock"), - require("lock-without-codes") + [capabilities.refresh.commands.refresh.NAME] = capability_handlers.refresh, + }, }, - lifecycle_handlers = { - doConfigure = do_configure, - added = device_added, - init = init, + supported_capabilities = { + capabilities.lock, + capabilities.lockCredentials, + capabilities.lockUsers, + capabilities.battery, }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_lock_driver, zigbee_lock_driver.supported_capabilities) -local lock = ZigbeeDriver("zigbee-lock", zigbee_lock_driver) -lock:run() +local driver = ZigbeeDriver("zigbee-lock", zigbee_lock_driver) +driver:run() diff --git a/drivers/SmartThings/zigbee-lock/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-lock/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua new file mode 100644 index 0000000000..81ff8f0e8a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local consts = require "lock_utils.constants" + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false + if not slga_migrated then + local subdriver = require("legacy-handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua new file mode 100644 index 0000000000..b9d5ce78ad --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/init.lua @@ -0,0 +1,495 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Zigbee Driver utilities +local device_management = require "st.zigbee.device_management" + +-- Zigbee Spec Utils +local clusters = require "st.zigbee.zcl.clusters" +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration + +-- Capabilities +local capabilities = require "st.capabilities" +local Battery = capabilities.battery +local Lock = capabilities.lock +local LockCodes = capabilities.lockCodes + +-- Enums +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType +local ProgrammingEventCodeEnum = LockCluster.types.ProgramEventCode + +local socket = require "cosock.socket" +local lock_utils = require "legacy-handlers.legacy_lock_utils" + +local DELAY_LOCK_EVENT = "_delay_lock_event" +local MAX_DELAY = 10 + +local reload_all_codes = function(driver, device, command) + -- starts at first user code index then iterates through all lock codes as they come in + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) == nil) then + device:send(LockCluster.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) == nil) then + device:send(LockCluster.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) == nil) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + end + if (device:get_field(lock_utils.CHECKING_CODE) == nil) then device:set_field(lock_utils.CHECKING_CODE, 0) end + device:emit_event(LockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) + device:send(LockCluster.server.commands.GetPINCode(device, device:get_field(lock_utils.CHECKING_CODE))) +end + +local refresh = function(driver, device, cmd) + device:refresh() + device:send(LockCluster.attributes.LockState:read(device)) + device:send(Alarm.attributes.AlarmCount:read(device)) + -- we can't determine from fingerprints if devices support lock codes, so + -- here in the driver we'll do a check once to see if the device responds here + -- and if it does, we'll switch it to a profile with lock codes + if not device:supports_capability_by_id(LockCodes.ID) and not device:get_field(lock_utils.CHECKED_CODE_SUPPORT) then + device:send(LockCluster.attributes.NumberOfPINUsersSupported:read(device)) + -- we won't make this value persist because it's not that important + device:set_field(lock_utils.CHECKED_CODE_SUPPORT, true) + end +end + +local do_configure = function(self, device) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1)) + + device:send(device_management.build_bind_request(device, LockCluster.ID, self.environment_info.hub_zigbee_eui)) + device:send(LockCluster.attributes.LockState:configure_reporting(device, 0, 3600, 0)) + + device:send(device_management.build_bind_request(device, Alarm.ID, self.environment_info.hub_zigbee_eui)) + device:send(Alarm.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0)) + + -- Don't send a reload all codes if this is a part of migration + if device.data.lockCodes == nil or device:get_field(lock_utils.MIGRATION_RELOAD_SKIPPED) == true then + device.thread:call_with_delay(2, function(d) + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end) + else + device:set_field(lock_utils.MIGRATION_RELOAD_SKIPPED, true, { persist = true }) + end +end + +local alarm_handler = function(driver, device, zb_mess) + local ALARM_REPORT = { + [0] = Lock.lock.unknown(), + [1] = Lock.lock.unknown(), + -- Events 16-19 are low battery events, but are presented as descriptionText only + } + if (ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value] ~= nil) then + device:emit_event(ALARM_REPORT[zb_mess.body.zcl_body.alarm_code.value]) + end +end + +local get_pin_response_handler = function(driver, device, zb_mess) + local event = LockCodes.codeChanged("", { state_change = true }) + local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) + event.data = {codeName = lock_utils.get_code_name(device, code_slot)} + if (zb_mess.body.zcl_body.user_status.value == UserStatusEnum.OCCUPIED_ENABLED) then + -- Code slot is occupied + event.value = code_slot .. lock_utils.get_change_type(device, code_slot) + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = event.data.codeName + device:emit_event(event) + lock_utils.lock_codes_event(device, lock_codes) + lock_utils.reset_code_state(device, code_slot) + else + -- Code slot is unoccupied + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + -- Code has been deleted + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + else + -- Code is unset + event.value = code_slot .. " unset" + device:emit_event(event) + end + end + + code_slot = tonumber(code_slot) + if (code_slot == device:get_field(lock_utils.CHECKING_CODE)) then + -- the code we're checking has arrived + local last_slot = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) - 1 + if (code_slot >= last_slot) then + device:emit_event(LockCodes.scanCodes("Complete", { visibility = { displayed = false } })) + device:set_field(lock_utils.CHECKING_CODE, nil) + else + local checkingCode = device:get_field(lock_utils.CHECKING_CODE) + 1 + device:set_field(lock_utils.CHECKING_CODE, checkingCode) + device:send(LockCluster.server.commands.GetPINCode(device, checkingCode)) + end + end +end + +local programming_event_handler = function(driver, device, zb_mess) + local event = LockCodes.codeChanged("", { state_change = true }) + local code_slot = tostring(zb_mess.body.zcl_body.user_id.value) + event.data = {} + if (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.MASTER_CODE_CHANGED) then + -- Master code changed + event.value = "0 set" + event.data = {codeName = "Master Code"} + device:emit_event(event) + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_DELETED) then + if (zb_mess.body.zcl_body.user_id.value == 0xFF) then + -- All codes deleted + for cs, _ in pairs(lock_utils.get_lock_codes(device)) do + lock_utils.code_deleted(device, cs) + end + lock_utils.lock_codes_event(device, {}) + else + -- One code deleted + if (lock_utils.get_lock_codes(device)[code_slot] ~= nil) then + lock_utils.lock_codes_event(device, lock_utils.code_deleted(device, code_slot)) + end + end + elseif (zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_ADDED or + zb_mess.body.zcl_body.program_event_code.value == ProgrammingEventCodeEnum.PIN_CODE_CHANGED) then + -- Code added or changed + local change_type = lock_utils.get_change_type(device, code_slot) + local code_name = lock_utils.get_code_name(device, code_slot) + event.value = code_slot .. change_type + event.data = {codeName = code_name} + device:emit_event(event) + if (change_type == " set") then + local lock_codes = lock_utils.get_lock_codes(device) + lock_codes[code_slot] = code_name + lock_utils.lock_codes_event(device, lock_codes) + end + end +end + +local handle_max_codes = function(driver, device, value) + if value.value ~= 0 then + -- Here's where we'll end up if we queried a lock whose profile does not have lock codes, + -- but it gave us a non-zero number of pin users, so we want to switch the profile + if not device:supports_capability_by_id(LockCodes.ID) then + device:try_update_metadata({ profile = "base-lock" }) -- switch to a lock with codes + lock_utils.populate_state_from_data(device) -- if this was a migrated device, try to migrate the lock codes + if not device:get_field(lock_utils.MIGRATION_COMPLETE) then -- this means we didn't find any pre-migration lock codes + -- so we'll load them manually + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.reloadAllCodes.NAME, + args = {} + }) + end + end + device:emit_event(LockCodes.maxCodes(value.value, { visibility = { displayed = false } })) + end +end + +local handle_max_code_length = function(driver, device, value) + device:emit_event(LockCodes.maxCodeLength(value.value, { visibility = { displayed = false } })) +end + +local handle_min_code_length = function(driver, device, value) + device:emit_event(LockCodes.minCodeLength(value.value, { visibility = { displayed = false } })) +end + +local update_codes = function(driver, device, command) + local delay = 0 + -- args.codes is json + for name, code in pairs(command.args.codes) do + -- these seem to come in the format "code[slot#]: code" + local code_slot = tonumber(string.gsub(name, "code", ""), 10) + if (code_slot ~= nil) then + if (code ~= nil and (code ~= "0" and code ~= "")) then + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.SetPINCode(device, + code_slot, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + code)) + end) + delay = delay + 2 + else + device.thread:call_with_delay(delay, function() + device:send(LockCluster.server.commands.ClearPINCode(device, code_slot)) + end) + delay = delay + 2 + end + device.thread:call_with_delay(delay, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, code_slot)) + end) + delay = delay + 2 + end + end +end + +local delete_code = function(driver, device, command) + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, command.args.codeSlot)) + device.thread:call_with_delay(2, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) + end) +end + +local request_code = function(driver, device, command) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) +end + +local set_code = function(driver, device, command) + if (command.args.codePIN == "") then + driver:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = { command.args.codeSlot, command.args.codeName } + }) + else + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.codeSlot, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.codePIN) + ) + if (command.args.codeName ~= nil) then + -- wait for confirmation from the lock to commit this to memory + -- Groovy driver has a lot more info passed here as a description string, may need to be investigated + local codeState = device:get_field(lock_utils.CODE_STATE) or {} + codeState["setName" .. command.args.codeSlot] = command.args.codeName + device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) + end + + device.thread:call_with_delay(4, function(d) + device:send(LockCluster.server.commands.GetPINCode(device, command.args.codeSlot)) + end) + end +end + +local name_slot = function(driver, device, command) + local code_slot = tostring(command.args.codeSlot) + local lock_codes = lock_utils.get_lock_codes(device) + if (lock_codes[code_slot] ~= nil) then + lock_codes[code_slot] = command.args.codeName + device:emit_event(LockCodes.codeChanged(code_slot .. " renamed", { state_change = true })) + lock_utils.lock_codes_event(device, lock_codes) + end +end + +local function init(driver, device) + lock_utils.populate_state_from_data(device) + -- temp fix before this can be changed to non-persistent + device:set_field(lock_utils.CODE_STATE, nil, { persist = true }) +end + +-- The following two functions are from the lock defaults. They are in the base driver temporarily +-- until the fix is widely released in the lua libs +local lock_state_handler = function(driver, device, value, zb_rx) + local attr = capabilities.lock.lock + local LOCK_STATE = { + [value.NOT_FULLY_LOCKED] = attr.unknown(), + [value.LOCKED] = attr.locked(), + [value.UNLOCKED] = attr.unlocked(), + [value.UNDEFINED] = attr.unknown(), + } + + -- this is where we decide whether or not we need to delay our lock event because we've + -- observed it coming before the event (or we're starting to compute the timer) + local delay = device:get_field(DELAY_LOCK_EVENT) or 100 + if (delay < MAX_DELAY) then + device.thread:call_with_delay(delay+.5, function () + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end) + else + device:set_field(DELAY_LOCK_EVENT, socket.gettime()) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end +end + +local lock_operation_event_handler = function(driver, device, zb_rx) + local event_code = zb_rx.body.zcl_body.operation_event_code.value + local source = zb_rx.body.zcl_body.operation_event_source.value + local OperationEventCode = require "st.zigbee.generated.zcl_clusters.DoorLock.types.OperationEventCode" + local METHOD = { + [0] = "keypad", + [1] = "command", + [2] = "manual", + [3] = "rfid", + [4] = "fingerprint", + [5] = "bluetooth" + } + local STATUS = { + [OperationEventCode.LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OperationEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OperationEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local event = STATUS[event_code] + if (event ~= nil) then + event["data"] = {} + if (source ~= 0 and event_code == OperationEventCode.AUTO_LOCK or + event_code == OperationEventCode.SCHEDULE_LOCK or + event_code == OperationEventCode.SCHEDULE_UNLOCK + ) then + event.data.method = "auto" + else + event.data.method = METHOD[source] + end + if (source == 0 and device:supports_capability_by_id(capabilities.lockCodes.ID)) then --keypad + local code_id = zb_rx.body.zcl_body.user_id.value + local code_name = "Code " .. code_id + local lock_codes = device:get_field("lockCodes") + if (lock_codes ~= nil and + lock_codes[code_id] ~= nil) then + code_name = lock_codes[code_id] + end + event.data = { method = METHOD[0], codeId = code_id .. "", codeName = code_name } + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + if device:get_latest_state( + device:get_component_id_for_endpoint(zb_rx.address_header.src_endpoint.value), + capabilities.lock.ID, + capabilities.lock.lock.ID) == event.value.value then + local preceding_event_time = device:get_field(DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < MAX_DELAY then + device:set_field(DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, event) + end +end + +local function lock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) +end + +local function unlock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) +end + +local migrate = function(driver, device, command) + local post_migration_consts = require "lock_utils.constants" + local LockUsers = capabilities.lockUsers + local LockCredentials = capabilities.lockCredentials + + -- set supported credentials + device:emit_event(LockCredentials.supportedCredentials({ post_migration_consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + + -- migrate max/min credential length + local cached_min_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.minCodeLength.NAME) + local cached_max_code_len = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodeLength.NAME) + if cached_min_code_len then + device:emit_event(LockCredentials.minPinCodeLen(cached_min_code_len, { visibility = { displayed = false } })) + end + if cached_max_code_len then + device:emit_event(LockCredentials.maxPinCodeLen(cached_max_code_len, { visibility = { displayed = false } })) + end + + -- migrate total codes supported + local cached_max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) + if cached_max_codes then + device:emit_event(LockCredentials.pinUsersSupported(cached_max_codes, { visibility = { displayed = false } })) + device:emit_event(LockUsers.totalUsersSupported(cached_max_codes, { visibility = { displayed = false } })) + end + + -- migrate stored lock codes slots and user names + local users, credentials = {}, {} + local cached_lock_codes = lock_utils.get_lock_codes(device) + local ordered_code_slots = {} + for code_slot_str, _ in pairs(cached_lock_codes) do + table.insert(ordered_code_slots, tonumber(code_slot_str)) + end + table.sort(ordered_code_slots) + for _, code_slot in ipairs(ordered_code_slots) do + local code_name = cached_lock_codes[tostring(code_slot)] + -- cached_lock_codes is formatted as { ["1"] = "Guest 1", ["2"] = "Guest 2" }, etc + if code_slot then + table.insert(users, { userIndex = code_slot, userType = "guest", userName = code_name }) + table.insert(credentials, { userIndex = code_slot, credentialIndex = code_slot, credentialType = post_migration_consts.CRED_TYPE_PIN, credentialName = code_name }) + end + end + -- manually ensure user/cred state is persisted, then emit events to populate the new capabilities with the migrated data. + device:set_field("persistedUsers", users, { persist = true }) + device:set_field("persistedCredentials", credentials, { persist = true }) + device:emit_event(LockUsers.users(users, { visibility = { displayed = false } })) + device:emit_event(LockCredentials.credentials(credentials, { visibility = { displayed = false } })) + + -- set and persist the migration complete flag and emit migrated event. Legacy subdriver will not be used again after this. + device:emit_event(LockCodes.migrated(true, { visibility = { displayed = false } })) + device:set_field(post_migration_consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + -- try to refresh all unstored state + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) +end + +local legacy_capabilities_driver = { + NAME = "Lock Driver Using LockCodes Capability", + supported_capabilities = { + Lock, + LockCodes, + Battery, + }, + zigbee_handlers = { + cluster = { + [Alarm.ID] = { + [Alarm.client.commands.Alarm.ID] = alarm_handler + }, + [LockCluster.ID] = { + [LockCluster.client.commands.GetPINCodeResponse.ID] = get_pin_response_handler, + [LockCluster.client.commands.ProgrammingEventNotification.ID] = programming_event_handler, + [LockCluster.client.commands.OperatingEventNotification.ID] = lock_operation_event_handler + } + }, + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.LockState.ID] = lock_state_handler, + [LockCluster.attributes.MaxPINCodeLength.ID] = handle_max_code_length, + [LockCluster.attributes.MinPINCodeLength.ID] = handle_min_code_length, + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes, + } + } + }, + capability_handlers = { + [LockCodes.ID] = { + [LockCodes.commands.updateCodes.NAME] = update_codes, + [LockCodes.commands.deleteCode.NAME] = delete_code, + [LockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [LockCodes.commands.requestCode.NAME] = request_code, + [LockCodes.commands.setCode.NAME] = set_code, + [LockCodes.commands.nameSlot.NAME] = name_slot, + [LockCodes.commands.migrate.NAME] = migrate, + }, + [Lock.ID] = { + [Lock.commands.lock.NAME] = lock, + [Lock.commands.unlock.NAME] = unlock, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh + } + }, + sub_drivers = require("legacy-handlers.sub_drivers"), + lifecycle_handlers = { + doConfigure = do_configure, + init = init, + }, + health_check = false, + can_handle = require("legacy-handlers.can_handle") +} + +return legacy_capabilities_driver diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua new file mode 100644 index 0000000000..6afa0aea3a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/legacy_lock_utils.lua @@ -0,0 +1,89 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local utils = require "st.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local LockCodes = capabilities.lockCodes + + +local legacy_lock_utils = { + -- Constants + LOCK_CODES = "lockCodes", + LOCK_USERS = "lockUsers", + CHECKING_CODE = "checkingCode", + CODE_STATE = "codeState", + MIGRATION_COMPLETE = "migrationComplete", + MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped", + CHECKED_CODE_SUPPORT = "checkedCodeSupport", +} + +legacy_lock_utils.get_lock_codes = function(device) + local lc = device:get_field(legacy_lock_utils.LOCK_CODES) + return lc ~= nil and lc or {} +end + +legacy_lock_utils.lock_codes_event = function(device, lock_codes) + device:set_field(legacy_lock_utils.LOCK_CODES, lock_codes, { persist = true } ) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(utils.deep_copy(lock_codes)), { visibility = { displayed = false } })) +end + + +function legacy_lock_utils.get_code_name(device, code_id) + if (device:get_field(legacy_lock_utils.CODE_STATE) ~= nil and device:get_field(legacy_lock_utils.CODE_STATE)["setName"..code_id] ~= nil) then + -- this means a code set operation succeeded + return device:get_field(legacy_lock_utils.CODE_STATE)["setName"..code_id] + elseif (legacy_lock_utils.get_lock_codes(device)[code_id] ~= nil) then + return legacy_lock_utils.get_lock_codes(device)[code_id] + else + return "Code " .. code_id + end +end + +function legacy_lock_utils.get_change_type(device, code_id) + if (legacy_lock_utils.get_lock_codes(device)[code_id] == nil) then + return " set" + else + return " changed" + end +end + +function legacy_lock_utils.reset_code_state(device, code_slot) + local codeState = device:get_field(legacy_lock_utils.CODE_STATE) + if (codeState ~= nil) then + codeState["setName".. code_slot] = nil + codeState["setCode".. code_slot] = nil + device:set_field(legacy_lock_utils.CODE_STATE, codeState, { persist = true }) + end +end + +function legacy_lock_utils.code_deleted(device, code_slot) + local lock_codes = legacy_lock_utils.get_lock_codes(device) + local event = LockCodes.codeChanged(code_slot.." deleted", { state_change = true }) + event.data = {codeName = legacy_lock_utils.get_code_name(device, code_slot)} + lock_codes[code_slot] = nil + device:emit_event(event) + legacy_lock_utils.reset_code_state(device, code_slot) + return lock_codes +end + +function legacy_lock_utils.populate_state_from_data(device) + if device.data.lockCodes ~= nil and device:get_field(legacy_lock_utils.MIGRATION_COMPLETE) ~= true then + -- build the lockCodes table + local lockCodes = {} + local lc_data = json.decode(device.data.lockCodes) + for k, v in pairs(lc_data) do + lockCodes[k] = v + end + -- Populate the devices `lockCodes` field + device:set_field(legacy_lock_utils.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) + -- Populate the devices state history cache + device.state_cache["main"] = device.state_cache["main"] or {} + device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} + device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} + + device:set_field(legacy_lock_utils.MIGRATION_COMPLETE, true, { persist = true }) + end +end + +return legacy_lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua new file mode 100644 index 0000000000..326df876df --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local fingerprints = require("legacy-handlers.lock-without-codes.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("legacy-handlers.lock-without-codes") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua new file mode 100644 index 0000000000..63ae82b46c --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local LOCK_WITHOUT_CODES_FINGERPRINTS = { + { model = "E261-KR0B0Z0-HA" }, + { mfr = "Danalock", model = "V3-BTZB" } +} + +return LOCK_WITHOUT_CODES_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua similarity index 66% rename from drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua index 7272991459..4522580297 100644 --- a/drivers/SmartThings/zigbee-lock/src/lock-without-codes/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/lock-without-codes/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local configurationMap = require "configurations" local clusters = require "st.zigbee.zcl.clusters" @@ -19,19 +9,7 @@ local capabilities = require "st.capabilities" local DoorLock = clusters.DoorLock local PowerConfiguration = clusters.PowerConfiguration -local LOCK_WITHOUT_CODES_FINGERPRINTS = { - { model = "E261-KR0B0Z0-HA" }, - { mfr = "Danalock", model = "V3-BTZB" } -} -local function can_handle_lock_without_codes(opts, driver, device) - for _, fingerprint in ipairs(LOCK_WITHOUT_CODES_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) local configuration = configurationMap.get_device_configuration(device) @@ -95,7 +73,7 @@ local lock_without_codes = { } } }, - can_handle = can_handle_lock_without_codes + can_handle = require("legacy-handlers.lock-without-codes.can_handle") } return lock_without_codes diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua new file mode 100644 index 0000000000..8cbfd6701f --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("legacy-handlers.yale"), + lazy_load_if_possible("legacy-handlers.yale-fingerprint-lock"), + lazy_load_if_possible("legacy-handlers.lock-without-codes") +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua new file mode 100644 index 0000000000..3baa8f368c --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local fingerprints = require("yale-fingerprint-lock.fingerprints") + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("legacy-handlers.yale-fingerprint-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua new file mode 100644 index 0000000000..7e982fd943 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale-fingerprint-lock/init.lua @@ -0,0 +1,27 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local LockCluster = clusters.DoorLock +local LockCodes = capabilities.lockCodes + +local YALE_FINGERPRINT_MAX_CODES = 0x1E +local handle_max_codes = function(driver, device, value) + device:emit_event(LockCodes.maxCodes(YALE_FINGERPRINT_MAX_CODES), { visibility = { displayed = false } }) +end + +local yale_fingerprint_lock_driver = { + NAME = "YALE Fingerprint Lock", + zigbee_handlers = { + attr = { + [LockCluster.ID] = { + [LockCluster.attributes.NumberOfPINUsersSupported.ID] = handle_max_codes + } + } + }, + can_handle = require("legacy-handlers.yale-fingerprint-lock.can_handle") +} + +return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua new file mode 100644 index 0000000000..832914f6ea --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function yale_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then + return true, require("legacy-handlers.yale") + end + return false +end + +return yale_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/yale/init.lua b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua similarity index 87% rename from drivers/SmartThings/zigbee-lock/src/yale/init.lua rename to drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua index 73e984036e..a85a9f8798 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/legacy-handlers/yale/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + -- Zigbee Spec Utils local clusters = require "st.zigbee.zcl.clusters" @@ -24,7 +15,7 @@ local LockCodes = capabilities.lockCodes local UserStatusEnum = LockCluster.types.DrlkUserStatus local UserTypeEnum = LockCluster.types.DrlkUserType -local lock_utils = (require "lock_utils") +local lock_utils = (require "legacy-handlers.legacy_lock_utils") local reload_all_codes = function(driver, device, command) -- starts at first user code index then iterates through all lock codes as they come in @@ -151,11 +142,7 @@ local yale_door_lock_driver = { [LockCodes.commands.setCode.NAME] = set_code } }, - - sub_drivers = { require("yale.yale-bad-battery-reporter") }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" - end + can_handle = require("legacy-handlers.yale.can_handle") } return yale_door_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua new file mode 100644 index 0000000000..371cf7a765 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/capabilities.lua @@ -0,0 +1,248 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local consts = require "lock_utils.constants" + +local Alarm = clusters.Alarms +local LockCluster = clusters.DoorLock +local UserStatusEnum = LockCluster.types.DrlkUserStatus +local UserTypeEnum = LockCluster.types.DrlkUserType + + +local CapabilityHandlers = {} + + +-- [[ LOCK CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.lock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.LockDoor(device)) +end + +function CapabilityHandlers.unlock(driver, device, command) + device:send_to_component(command.component, LockCluster.server.commands.UnlockDoor(device)) +end + + +-- [[ LOCK USERS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + -- Find the smallest positive userIndex not already in the table + local next_available_index = tables.next_index(device, "users") + local status = tables.add_entry(device, "users", { + userIndex = next_available_index, + userName = command.args.userName, + userType = command.args.userType, + }) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = next_available_index } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, status, additional_info) +end + +function CapabilityHandlers.update_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + local result_status + if not tables.find_entry(device, "users", command.args.userIndex) then + result_status = tables.add_entry(device, "users", { + userIndex = command.args.userIndex, + userName = command.args.userName, + userType = command.args.userType, + }) + else + result_status = tables.update_entry(device, "users", + command.args.userIndex, + { userName = command.args.userName, userType = command.args.userType } + ) + end + + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, result_status, additional_info) +end + +function CapabilityHandlers.delete_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.BUSY) + return + end + + local associated_credentials = tables.find_all_entries_by(device, "credentials", "userIndex", command.args.userIndex) + for _, associated_credential in ipairs(associated_credentials) do + -- Set busy state with the full user+credential context BEFORE injecting. + -- Injected capability commands are schema-validated, so extra args like userIndex + -- would be stripped. By setting device fields here we preserve the full context. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE, { + userIndex = command.args.userIndex, + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + }) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, + args = { + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + } + }) + end + if #associated_credentials == 0 then + -- No associated credentials: delete the user entry directly and report the result + local status = tables.delete_entry(device, "users", command.args.userIndex) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, status, additional_info) + end +end + +function CapabilityHandlers.delete_all_users(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + return + end + -- Set busy state with DELETE_ALL context BEFORE injecting so the response handler + -- knows to clear both tables and emit results for both capabilities. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE_ALL, {}) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteAllCredentials.NAME, + args = { credentialType = consts.CRED_TYPE_PIN } + }) +end + + +-- [[ LOCK CREDENTIALS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + -- A userIndex of 0 means "auto-assign the next available slot" + local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex + local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex + + if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) + return + end + + -- Set busy state and attempt to set the PIN on-device. + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = user_index, + credentialIndex = cred_index, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(LockCluster.server.commands.SetPINCode(device, + cred_index, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.credentialData) + ) +end + +function CapabilityHandlers.update_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) + return + end + + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { + userIndex = command.args.userIndex, + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(LockCluster.server.commands.SetPINCode(device, + command.args.credentialIndex, + UserStatusEnum.OCCUPIED_ENABLED, + UserTypeEnum.UNRESTRICTED, + command.args.credentialData) + ) +end + +function CapabilityHandlers.delete_credential(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == consts.LOCK_USERS.DELETE then + -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, credential_args.credentialIndex)) + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) + else + -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. + local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) + if not found_cred then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) + return + end + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, { + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + userIndex = found_cred.userIndex, + }) + device:send(LockCluster.attributes.SendPINOverTheAir:write(device, true)) + device:send(LockCluster.server.commands.ClearPINCode(device, command.args.credentialIndex)) + end +end + +function CapabilityHandlers.delete_all_credentials(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then + -- Injected by deleteAllUsers; busy state was already set with LOCK_USERS.DELETE_ALL context. + device:send(LockCluster.server.commands.ClearAllPINCodes(device)) + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + else + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE_ALL, command.args) + device:send(LockCluster.server.commands.ClearAllPINCodes(device)) + end +end + + +-- [[ REFRESH CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.refresh(driver, device, cmd) + device:refresh() + device:send(LockCluster.attributes.LockState:read(device)) + device:send(Alarm.attributes.AlarmCount:read(device)) + + if device:supports_capability(capabilities.lockCredentials) then + -- If we are missing the cached values for these attributes, read them so we can properly manage them locally + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.maxPinCodeLen.NAME) == nil) then + device:send(clusters.DoorLock.attributes.MaxPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) == nil) then + device:send(clusters.DoorLock.attributes.MinPINCodeLength:read(device)) + end + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) or + (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then + device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) + end + elseif not device:supports_capability(capabilities.lockCodes) then + -- Generically fingerprinted devices may support PINs even though the profile does not yet + -- reflect it. Read NumberOfPINUsersSupported so the response handler can trigger a profile + -- migration when appropriate. + device:send(clusters.DoorLock.attributes.NumberOfPINUsersSupported:read(device)) + end +end + +return CapabilityHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua new file mode 100644 index 0000000000..9d1c53947a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_handlers/zigbee_responses.lua @@ -0,0 +1,438 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local socket = require "cosock.socket" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" + +local ZigbeeHandlers = {} + + +-- [[ DOOR LOCK CLUSTER COMMAND RESPONSES ]] -- + +function ZigbeeHandlers.set_pin_code_response(driver, device, zb_rx) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + -- zb response values + local set_pin_code_status = zb_rx.body.zcl_body.status.value + + local SetCodeStatus = clusters.DoorLock.types.DrlkSetCodeStatus + -- SUCCESS = 0 + -- GENERAL_FAILURE = 1 + -- MEMORY_FULL = 2 + -- DUPLICATE_CODE = 3 + + -- mapped failures states + local RESPONSE_RESULT_MAP = { + [SetCodeStatus.GENERAL_FAILURE] = consts.COMMAND_RESULT.FAILURE, + [SetCodeStatus.MEMORY_FULL] = consts.COMMAND_RESULT.RESOURCE_EXHAUSTED, + [SetCodeStatus.DUPLICATE_CODE] = consts.COMMAND_RESULT.DUPLICATE, + } + + -- apply result based on response and identify command result status + local result_status + if set_pin_code_status == SetCodeStatus.SUCCESS then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + tables.add_entry(device, "users", { + userIndex = credential_args_in_use.userIndex, + userName = "Guest " .. credential_args_in_use.userIndex, -- default + userType = "guest", -- default + }) + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = "Guest " .. credential_args_in_use.userIndex, -- default + }) + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + result_status = consts.COMMAND_RESULT.SUCCESS + end + elseif RESPONSE_RESULT_MAP[set_pin_code_status] then + result_status = RESPONSE_RESULT_MAP[set_pin_code_status] + else + result_status = consts.COMMAND_RESULT.FAILURE + end + + -- emit command result + if command_in_progress then + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) + lock_utils.clear_busy_state(device) + end +end + + +function ZigbeeHandlers.clear_all_pin_codes_response(driver, device, zb_rx) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- zb response values + local clear_pin_code_status = zb_rx.body.zcl_body.status.value + + local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus + -- PASS = 0 + -- FAIL = 1 + + -- apply result and identify command result statuses + local user_status, credential_status + if clear_pin_code_status == ResponseStatus.PASS then + -- Only clear the users table when this response is for a deleteAllUsers flow. + if command_in_progress == consts.LOCK_USERS.DELETE_ALL then + user_status = tables.delete_all_entries(device, "users") + end + credential_status = tables.delete_all_entries(device, "credentials") + elseif clear_pin_code_status == ResponseStatus.FAIL then + user_status = consts.COMMAND_RESULT.FAILURE + credential_status = consts.COMMAND_RESULT.FAILURE + end + + -- emit command results + if command_in_progress == consts.LOCK_USERS.DELETE_ALL then + -- deleteAllUsers injects deleteAllCredentials, so both command results should be emitted. + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, user_status) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE_ALL then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, credential_status) + end + lock_utils.clear_busy_state(device) +end + + +function ZigbeeHandlers.clear_pin_code_response(driver, device, zb_rx) + -- cached values from capability command + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- guard: avoid nil errors if the programming event failsafe already handled this command and cleared the busy state + if not credential_args_in_use then return end + + -- zb response values + local clear_pin_code_status = zb_rx.body.zcl_body.status.value + + local ResponseStatus = clusters.DoorLock.types.DrlkPassFailStatus + -- PASS = 0 + -- FAIL = 1 + + -- apply result and identify command result statuses + local user_status, credential_status + if clear_pin_code_status == ResponseStatus.PASS then + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + if command_in_progress == consts.LOCK_USERS.DELETE or tables.find_entry_by(device, "credentials", "userIndex", credential_args_in_use.userIndex) == nil then + user_status = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + end + elseif clear_pin_code_status == ResponseStatus.FAIL then + user_status = consts.COMMAND_RESULT.FAILURE + credential_status = consts.COMMAND_RESULT.FAILURE + end + + -- emit command results + if command_in_progress == consts.LOCK_USERS.DELETE then + -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + local user_info = user_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, user_status, user_info) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE then + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + end + lock_utils.clear_busy_state(device) +end + + +function ZigbeeHandlers.get_pin_code_response(driver, device, zb_rx) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- zb response values + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + + if command_in_progress == consts.SYNC.CODES_FROM_LOCK then + -- Only add entries for slots that are actually occupied on the lock. + local user_status = zb_rx.body.zcl_body.user_status.value + if user_status == clusters.DoorLock.types.DrlkUserStatus.OCCUPIED_ENABLED then + -- If an entry already exists at this index, this will be a no-op. + tables.add_entry(device, "users", { + userIndex = user_id, + userName = "Guest " .. user_id, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = user_id, + credentialIndex = user_id, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. user_id, + }) + -- reset the consecutive unoccupied codes counter since we found an occupied code + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) + else + local consecutive_unoccupied_codes = (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1 + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, consecutive_unoccupied_codes) + end + + if user_id >= tables.get_max_entries(device, "credentials") or + (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then + -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. + device:set_field(consts.SYNC.CODE_INDEX, nil) + lock_utils.clear_busy_state(device) + else + local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 + device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, synced_code_index)) + end + end +end + + +-- [[ DOOR LOCK CLUSTER EVENT NOTIFICATIONS ]] -- + +function ZigbeeHandlers.programming_event_notification(driver, device, zb_rx) + -- zb response values + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + local event_code = tonumber(zb_rx.body.zcl_body.program_event_code.value) + + if device:get_manufacturer() == "ASSA ABLOY iRevo" or device:get_manufacturer() == "Yale" then + if user_id >= 256 then -- Index is incorrectly written on these devices. Attempt to shift it to get an actual value + user_id = user_id >> 8 + end + end + + local ProgramEventCode = clusters.DoorLock.types.ProgramEventCode + -- MASTER_CODE_CHANGED = 1 + -- PIN_CODE_ADDED = 2 + -- PIN_CODE_DELETED = 3 + -- PIN_CODE_CHANGED = 4 + -- RFID_CODE_ADDED = 5 + -- RFID_CODE_DELETED = 6 + + + -- failsafes: handle the case where we receive a programming event notification for a command we've just sent, + -- which can be verified by checking that the user id matches the one used in the command, but before we receive + -- the response for that command. This gives us double the chance to handle the command + -- in case the response handler doesn't execute properly for some reason. + -- + -- cached values from capability command, if applicable. + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + if command_in_progress and credential_args_in_use.credentialIndex == user_id then + local result_status + if event_code == ProgramEventCode.PIN_CODE_ADDED then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + tables.add_entry(device, "users", { + userIndex = credential_args_in_use.userIndex, + userName = "Guest " .. credential_args_in_use.userIndex, -- default + userType = "guest", -- default + }) + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args_in_use.userIndex, + credentialIndex = credential_args_in_use.credentialIndex, + credentialType = credential_args_in_use.credentialType, + credentialName = credential_args_in_use.credentialName, -- optional + }) + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + -- some devices emit PIN_CODE_ADDED even for UPDATE events + result_status = consts.COMMAND_RESULT.SUCCESS + end + elseif event_code == ProgramEventCode.PIN_CODE_DELETED and (command_in_progress == consts.LOCK_CREDENTIALS.DELETE or command_in_progress == consts.LOCK_USERS.DELETE) then + result_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + -- the deleteUser command injects a deleteCredential command, so ensure the lockUsers command results is emitted in this case. + if command_in_progress == consts.LOCK_USERS.DELETE or tables.find_entry_by(device, "credentials", "userIndex", credential_args_in_use.userIndex) == nil then + local user_status = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + local user_info = user_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + if command_in_progress == consts.LOCK_USERS.DELETE then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, user_status, user_info) + command_in_progress = consts.LOCK_CREDENTIALS.DELETE -- ensure the lockCredentials result is emitted after this with the correct command context + end + end + elseif event_code == ProgramEventCode.PIN_CODE_CHANGED and command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + result_status = consts.COMMAND_RESULT.SUCCESS + end + + if result_status then + lock_utils.emit_command_result(device, + capabilities.lockCredentials, + command_in_progress, + result_status, + { userIndex = credential_args_in_use.userIndex, credentialIndex = credential_args_in_use.credentialIndex } + ) + lock_utils.clear_busy_state(device) + return + end + end + + -- handle the case where we receive a programming event notification for a code we've just deleted, + if event_code == ProgramEventCode.PIN_CODE_ADDED then + -- if no "addCredential" command is in progress, check if the credential is already stored locally. + local credential = tables.find_entry(device, "credentials", user_id) + if not credential then + -- check what user slot we have to associate this with and add a default user + local next_index = tables.next_index(device, "users") + if next_index and next_index <= tables.get_max_entries(device, "users") then + tables.add_entry(device, "users", { + userIndex = next_index, + userName = "Guest " .. next_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = next_index, + credentialIndex = user_id, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. next_index, + }) + end + end + elseif event_code == ProgramEventCode.PIN_CODE_DELETED then + -- check if a credential exists locally for this user id, then + -- try to delete the entries in our tables corresponding to this code. + local credential = tables.find_entry(device, "credentials", user_id) + if credential then + tables.delete_entry(device, "credentials", user_id) + -- Only delete the user if they have no remaining credentials + if tables.find_entry_by(device, "credentials", "userIndex", credential.userIndex) == nil then + tables.delete_entry(device, "users", credential.userIndex) + end + end + end +end + +function ZigbeeHandlers.operating_event_notification(driver, device, zb_rx) + local op_event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) + local op_event_source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + + -- get lock event or return + local OpEventCode = clusters.DoorLock.types.OperationEventCode + local OP_EVENT_CODE_CAPABILITY_MAP = { + [OpEventCode.LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local lock_event = OP_EVENT_CODE_CAPABILITY_MAP[op_event_code] + if not lock_event then return end + lock_event.data = {} + + -- get method of lock event + local OpEventSource = clusters.DoorLock.types.DrlkOperationEventSource + local OP_EVENT_SOURCE_CAPABILITY_MAP = { + [OpEventSource.KEYPAD] = "keypad", + [OpEventSource.RF] = "command", + [OpEventSource.MANUAL] = "manual", + [OpEventSource.RFID] = "rfid", + } + if op_event_code == OpEventCode.AUTO_LOCK or + op_event_code == OpEventCode.SCHEDULE_LOCK or + op_event_code == OpEventCode.SCHEDULE_UNLOCK + then + lock_event.data.method = "auto" + else + lock_event.data.method = OP_EVENT_SOURCE_CAPABILITY_MAP[op_event_source] or "manual" + end + + -- get stored lockUsers data if applicable + if op_event_source == OpEventSource.KEYPAD and device:supports_capability(capabilities.lockUsers) then + local credential = tables.find_entry(device, "credentials", user_id) + local associated_user = credential and tables.find_entry_by(device, "users", "userIndex", credential.userIndex) or nil + if associated_user then + lock_event.data.userIndex = associated_user.userIndex + lock_event.data.userName = associated_user.userName + lock_event.data.userType = associated_user.userType + else + lock_event.data.userIndex = user_id + lock_event.data.userName = "Guest " .. user_id -- default + end + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + local endpoint_id = zb_rx.address_header.src_endpoint.value + if lock_event.value.value == device:get_latest_state( + device:get_component_id_for_endpoint(endpoint_id), + capabilities.lock.ID, + capabilities.lock.lock.ID + ) then + local preceding_event_time = device:get_field(consts.DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < consts.MAX_DELAY then + device:set_field(consts.DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(endpoint_id, lock_event) +end + + +-- [[ DOOR LOCK CLUSTER ATTRIBUTES ]] -- + +function ZigbeeHandlers.lock_state(driver, device, value, zb_rx) + local attr = capabilities.lock.lock + local LOCK_STATE = { + [value.NOT_FULLY_LOCKED] = attr.unknown(), + [value.LOCKED] = attr.locked(), + [value.UNLOCKED] = attr.unlocked(), + [value.UNDEFINED] = attr.unknown(), + } + + -- this is where we decide whether or not we need to delay our lock event because we've + -- observed it coming before the event (or we're starting to compute the timer) + local delay = device:get_field(consts.DELAY_LOCK_EVENT) or 100 + if (delay < consts.MAX_DELAY) then + device.thread:call_with_delay(delay+.5, function () + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end) + else + device:set_field(consts.DELAY_LOCK_EVENT, socket.gettime()) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, LOCK_STATE[value.value] or attr.unknown()) + end +end + +function ZigbeeHandlers.max_pin_code_length(driver, device, value) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +function ZigbeeHandlers.min_pin_code_length(driver, device, value) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(value.value, { visibility = { displayed = false } })) +end + +function ZigbeeHandlers.number_of_pin_users_supported(driver, device, value) + if not device:supports_capability_by_id(capabilities.lockCodes.ID) and value.value > 0 then + -- this device was generically fingerprinted, but supports PIN users, so we should migrate it. + device:try_update_metadata({ profile = "base-lock" }) + end + if device:supports_capability(capabilities.lockCredentials) then + device:emit_event(capabilities.lockCredentials.pinUsersSupported(value.value, {visibility = {displayed = false}})) + end + if device:supports_capability(capabilities.lockUsers) then + device:emit_event(capabilities.lockUsers.totalUsersSupported(value.value, {visibility = {displayed = false}})) + end +end + + +-- [[ ALARMS CLUSTER COMMANDS ]] -- + +function ZigbeeHandlers.alarm(driver, device, zb_rx) + local ALARM_REPORT = { + [0] = capabilities.lock.lock.unknown(), + [1] = capabilities.lock.lock.unknown(), + -- Events 16-19 are low battery events, but are presented as descriptionText only + } + if (ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value] ~= nil) then + device:emit_event(ALARM_REPORT[zb_rx.body.zcl_body.alarm_code.value]) + end +end + + +return ZigbeeHandlers diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils.lua deleted file mode 100644 index 0a36a9685e..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/lock_utils.lua +++ /dev/null @@ -1,98 +0,0 @@ --- Copyright 2022 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. -local utils = require "st.utils" -local capabilities = require "st.capabilities" -local json = require "st.json" -local LockCodes = capabilities.lockCodes - - -local lock_utils = { - -- Constants - LOCK_CODES = "lockCodes", - CHECKING_CODE = "checkingCode", - CODE_STATE = "codeState", - MIGRATION_COMPLETE = "migrationComplete", - MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped", - CHECKED_CODE_SUPPORT = "checkedCodeSupport" -} - -lock_utils.get_lock_codes = function(device) - local lc = device:get_field(lock_utils.LOCK_CODES) - return lc ~= nil and lc or {} -end - -lock_utils.lock_codes_event = function(device, lock_codes) - device:set_field(lock_utils.LOCK_CODES, lock_codes, { persist = true } ) - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(utils.deep_copy(lock_codes)), { visibility = { displayed = false } })) -end - - -function lock_utils.get_code_name(device, code_id) - if (device:get_field(lock_utils.CODE_STATE) ~= nil and device:get_field(lock_utils.CODE_STATE)["setName"..code_id] ~= nil) then - -- this means a code set operation succeeded - return device:get_field(lock_utils.CODE_STATE)["setName"..code_id] - elseif (lock_utils.get_lock_codes(device)[code_id] ~= nil) then - return lock_utils.get_lock_codes(device)[code_id] - else - return "Code " .. code_id - end -end - -function lock_utils.get_change_type(device, code_id) - if (lock_utils.get_lock_codes(device)[code_id] == nil) then - return " set" - else - return " changed" - end -end - -function lock_utils.reset_code_state(device, code_slot) - local codeState = device:get_field(lock_utils.CODE_STATE) - if (codeState ~= nil) then - codeState["setName".. code_slot] = nil - codeState["setCode".. code_slot] = nil - device:set_field(lock_utils.CODE_STATE, codeState, { persist = true }) - end -end - -function lock_utils.code_deleted(device, code_slot) - local lock_codes = lock_utils.get_lock_codes(device) - local event = LockCodes.codeChanged(code_slot.." deleted", { state_change = true }) - event.data = {codeName = lock_utils.get_code_name(device, code_slot)} - lock_codes[code_slot] = nil - device:emit_event(event) - lock_utils.reset_code_state(device, code_slot) - return lock_codes -end - -function lock_utils.populate_state_from_data(device) - if device.data.lockCodes ~= nil and device:get_field(lock_utils.MIGRATION_COMPLETE) ~= true then - -- build the lockCodes table - local lockCodes = {} - local lc_data = json.decode(device.data.lockCodes) - for k, v in pairs(lc_data) do - lockCodes[k] = v - end - -- Populate the devices `lockCodes` field - device:set_field(lock_utils.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) - -- Populate the devices state history cache - device.state_cache["main"] = device.state_cache["main"] or {} - device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} - device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} - - device:set_field(lock_utils.MIGRATION_COMPLETE, true, { persist = true }) - end -end - -return lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua new file mode 100644 index 0000000000..78537182f1 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/constants.lua @@ -0,0 +1,47 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lock_constants = {} + +lock_constants.DRIVER_STATE = { + BUSY = "busy", + COMMAND_IN_PROGRESS = "commandInProgress", + CREDENTIAL_ARGS_IN_USE = "currentCredential", + SLGA_MIGRATED = "slgaMigrated", +} + +lock_constants.SYNC = { + CODES_FROM_LOCK = "syncCodesFromLock", + CODE_INDEX = "syncCodeIndex", + CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", +} + +lock_constants.COMMAND_RESULT = { + SUCCESS = "success", + FAILURE = "failure", + DUPLICATE = "duplicate", + OCCUPIED = "occupied", + INVALID_COMMAND = "invalidCommand", + RESOURCE_EXHAUSTED = "resourceExhausted", + BUSY = "busy" +} + +lock_constants.LOCK_CREDENTIALS = { + ADD = "addCredential", + UPDATE = "updateCredential", + DELETE = "deleteCredential", + DELETE_ALL = "deleteAllCredentials" +} + +lock_constants.LOCK_USERS = { + ADD = "addUser", + UPDATE = "updateUser", + DELETE = "deleteUser", + DELETE_ALL = "deleteAllUsers" +} + +lock_constants.CRED_TYPE_PIN = "pin" +lock_constants.DELAY_LOCK_EVENT = "_delay_lock_event" +lock_constants.MAX_DELAY = 10 + +return lock_constants diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua new file mode 100644 index 0000000000..bfe3562344 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/tables.lua @@ -0,0 +1,268 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT + +local table_utils = {} + +-- DEFS describes how each capability-backed state table is structured: +-- +-- capability SmartThings capability (used for device support checks and to get latest state) +-- attribute Capability attribute function (used to emit state) +-- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table +-- match_key Key used to identify entries in flat tables +-- required_keys Keys that must be non-nil when adding an entry +-- persistent_field device:set_field key used to back up the table across restarts +-- + +local DEFS = { + users = { + capability = capabilities.lockUsers, + attribute = capabilities.lockUsers.users, + max_entries = capabilities.lockUsers.totalUsersSupported, + match_key = "userIndex", + required_keys = {"userIndex", "userType"}, + persistent_field = "persistedUsers", + }, + credentials = { + capability = capabilities.lockCredentials, + attribute = capabilities.lockCredentials.credentials, + max_entries = capabilities.lockCredentials.pinUsersSupported, + match_key = "credentialIndex", + required_keys = {"userIndex", "credentialIndex", "credentialType"}, + persistent_field = "persistedCredentials", + } +} + +-- Resolve a table name to its definition. Logs an error and returns nil if unknown. +local function resolve_table_def(device, table_name) + local def = DEFS[table_name] + if not def then + device.log.error(string.format("table_helpers: unknown table %q", table_name)) + end + return def +end + +-- Validate that an entry table contains all required keys. +local function validate_entry(device, entry, required_keys) + for _, key in ipairs(required_keys or {}) do + if entry[key] == nil then + device.log.error(string.format("table_helpers: entry missing required key %q", key)) + return false + end + end + return true +end + +-- Write the current table contents to the device's persistent field store so that +-- the state survives driver restarts and can be restored if the capability state +-- cache is wiped. +local function persist_table(device, def, data) + device:set_field(def.persistent_field, st_utils.deep_copy(data), { persist = true }) +end + +-- Read the current state for a table and return a deep-copied array. +-- Accepts either a string table name ("users", "credentials") or a DEFS entry directly. +-- Returns nil (with a warning) if the capability is unsupported by the device. +-- When the capability state cache has been wiped (get_latest_state returns nil), +-- falls back to the persistent field store so that callers always receive the +-- last-known state rather than an empty table. +--- @return table[] | nil +function table_utils.get_state(device, name_or_def) + local def = type(name_or_def) == "string" and resolve_table_def(device, name_or_def) or name_or_def + if not def then return nil end + if not device:supports_capability(def.capability, "main") then + device.log.warn(string.format( + "table_helpers: device does not support capability %q", def.capability.ID + )) + return + end + local state = device:get_latest_state("main", def.capability.ID, def.attribute.NAME) + if state ~= nil then + return st_utils.deep_copy(state) + end + -- Capability state cache is absent (e.g. after a hub reboot); fall back to the + -- persistent store so that callers see the last-known table contents. + return st_utils.deep_copy(device:get_field(def.persistent_field) or {}) +end + +-- Find an entry in a named table where the match_key equals value. +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry(device, table_name, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[def.match_key] == value then return entry end + end + return nil +end + +-- Find an entry in a named table where entry[key] equals value (arbitrary key search). +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[key] == value then return entry end + end + return nil +end + +-- Find all entries in a named table where entry[key] equals value (arbitrary key search). +-- Returns an array of matching entries, or an empty array if none found. +function table_utils.find_all_entries_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return {} end + local t = table_utils.get_state(device, def) + if not t then return {} end + local matches = {} + for _, entry in ipairs(t) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches +end + +-- Return the lowest positive integer not yet used as the match_key in the named table. +-- Used to auto-assign the next available slot for a new entry. +function table_utils.next_index(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return 1 end + local t = table_utils.get_state(device, def) or {} + local occupied = {} + for _, entry in ipairs(t) do occupied[entry[def.match_key]] = true end + local idx = 1 + while occupied[idx] do idx = idx + 1 end + return idx +end + +function table_utils.get_max_entries(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return end + return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing +end + +-- Add an entry to a named table. The entry must satisfy all required_keys for +-- that table. An entry whose match_key value already exists in the +-- table is skipped to prevent duplicates. If the table has a max_entries limit, +-- entries that exceed the limit are not added. +function table_utils.add_entry(device, table_name, entry) + device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + if not validate_entry(device, entry, def.required_keys) then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + if #t >= table_utils.get_max_entries(device, table_name) then + device.log.warn(string.format( + "table_helpers: cannot add entry to %q, max entries reached", table_name + )) + return COMMAND_RESULT.RESOURCE_EXHAUSTED + end + + -- Object entry: skip if an entry with the same match_key value already exists + if def.match_key then + for _, existing in ipairs(t) do + if existing[def.match_key] == entry[def.match_key] then + device.log.warn(string.format( + "table_helpers: entry with %s == %s already exists in %q, skipping", + def.match_key, tostring(entry[def.match_key]), table_name + )) + return COMMAND_RESULT.OCCUPIED + end + end + end + + table.insert(t, st_utils.deep_copy(entry)) + + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS +end + + +--- Update fields of an existing entry in a table. +--- The entry to update is identified by the match_key parameter in DEFS. +function table_utils.update_entry(device, table_name, match_value, updates) + device.log.debug("table_helpers: attempting to update entry " .. match_value .. " in " .. table_name .. ": " .. st_utils.stringify_table(updates)) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + for _, entry in ipairs(t) do + if entry[def.match_key] == match_value then + for k, v in pairs(updates) do + entry[k] = v + end + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + + device.log.warn(string.format( + "table_helpers: no entry found in %q with %s == %s", + table_name, def.match_key, tostring(match_value) + )) + return COMMAND_RESULT.FAILURE +end + + +-- Delete an entry from a table. +-- +-- Returns SUCCESS, or FAILURE if nothing matched. +function table_utils.delete_entry(device, table_name, matcher) + device.log.debug("table_helpers: attempting to delete entry " .. matcher .. " from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + local predicate = function(entry) return entry[def.match_key] == matcher end + + for i, entry in ipairs(t) do + if predicate(entry) then + table.remove(t, i) + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + return COMMAND_RESULT.FAILURE +end + +-- Delete all entries from a table. +function table_utils.delete_all_entries(device, table_name) + device.log.debug("table_helpers: attempting to delete all entries from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + device:emit_event(def.attribute({}, {visibility = {displayed = false}})) + persist_table(device, def, {}) + return COMMAND_RESULT.SUCCESS +end + +-- Restore capability state from the persistent field store. +-- Called during init to re-emit table events if the capability state cache +-- has been wiped (e.g. after a hub reboot). Only emits for tables that have +-- persisted data, are in a nil state, and whose capability is supported by the device. +function table_utils.restore_from_persistent_store(device) + for _, internal in pairs(DEFS) do + if device:supports_capability(internal.capability, "main") and + device:get_latest_state("main", internal.capability.ID, internal.attribute.NAME) == nil + then + local persisted = st_utils.deep_copy(device:get_field(internal.persistent_field)) + if persisted and #persisted > 0 then + device:emit_event(internal.attribute(persisted, {visibility = {displayed = false}})) + end + end + end +end + +return table_utils diff --git a/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua new file mode 100644 index 0000000000..90bdd96f33 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/lock_utils/utils.lua @@ -0,0 +1,66 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local consts = require "lock_utils.constants" + +local lock_utils = {} + +-- [[ BUSY STATE MANAGEMENT ]] -- + +-- Check if we are currently busy performing a task, or at least 10 seconds have passed since the busy state was last set. +-- If busy, return true. If not busy, clear any stale state and return false. +function lock_utils.is_device_busy(device) + local c_time = os.time() + local busy_since = device:get_field(consts.DRIVER_STATE.BUSY) or false + + if (busy_since == false) or (c_time - busy_since > 10) then + lock_utils.clear_busy_state(device) + return false + end + return true +end + +-- Set states that may be required when in busy state +function lock_utils.set_busy_state(device, command_name, command_args) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, command_name) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, command_args or {}) + device:set_field(consts.DRIVER_STATE.BUSY, os.time()) +end + +-- Clear states that were set when in busy state +function lock_utils.clear_busy_state(device) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, nil) + device:set_field(consts.DRIVER_STATE.BUSY, false) +end + + +-- [[ SYNC STATE MANAGEMENT ]] -- + +function lock_utils.sync_device_state(device) + local clusters = require "st.zigbee.zcl.clusters" + -- Per spec, this attribute should be a boolean set to True if it is ok for the door lock server to send PINs over the air. + device:send(clusters.DoorLock.attributes.SendPINOverTheAir:write(device, true)) + + -- if consts.SYNC.CODE_INDEX is nil, we haven't started syncing codes from the lock yet, + -- so start the process from 1. note: the Zigbee "Master Code" is associated with index 0. + if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then + device:set_field(consts.SYNC.CODE_INDEX, 1) + end + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) + device:send(clusters.DoorLock.server.commands.GetPINCode(device, device:get_field(consts.SYNC.CODE_INDEX))) +end + + +-- [[ COMMAND RESULT STATE MANAGEMENT ]] -- + +function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) + local info = additional_info or {} + info.commandName = command_name + info.statusCode = status_code + if capability then + device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) + end +end + +return lock_utils diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua new file mode 100644 index 0000000000..c483b2fe27 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function samsungsds_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "SAMSUNG SDS" then + return true, require("samsungsds") + end + return false +end + +return samsungsds_can_handle diff --git a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua index b529dd3fd1..e09a598efa 100644 --- a/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/samsungsds/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local clusters = require "st.zigbee.zcl.clusters" @@ -20,7 +10,9 @@ local cluster_base = require "st.zigbee.cluster_base" local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock local Lock = capabilities.lock -local lock_utils = require "lock_utils" +local consts = require "lock_utils.constants" +local tables = require "lock_utils.tables" +local socket = require "cosock.socket" local SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND = 0x1F local SAMSUNG_SDS_MFR_CODE = 0x0003 @@ -64,7 +56,8 @@ local function emit_event_if_latest_state_missing(device, component, capability, end local device_added = function(self, device) - lock_utils.populate_state_from_data(device) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. + emit_event_if_latest_state_missing(device, "main", capabilities.lock, capabilities.lock.lock.NAME, capabilities.lock.lock.unlocked()) device:emit_event(capabilities.battery.battery(100)) end @@ -78,10 +71,86 @@ end local battery_init = battery_defaults.build_linear_voltage_init(4.0, 6.0) local device_init = function(driver, device, event) + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- set migrated for all Samsung SDS devices. They do not require any legacy functionality. battery_init(driver, device, event) device:remove_monitored_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) device:remove_configured_attribute(clusters.PowerConfiguration.ID, clusters.PowerConfiguration.attributes.BatteryVoltage.ID) - lock_utils.populate_state_from_data(device) +end + +local operating_event_notification = function(driver, device, zb_rx) + local op_event_code = tonumber(zb_rx.body.zcl_body.operation_event_code.value) + local op_event_source = tonumber(zb_rx.body.zcl_body.operation_event_source.value) + + -- get lock event or return + local OpEventCode = clusters.DoorLock.types.OperationEventCode + local OP_EVENT_CODE_CAPABILITY_MAP = { + [OpEventCode.LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.ONE_TOUCH_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.KEY_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.AUTO_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.MANUAL_UNLOCK] = capabilities.lock.lock.unlocked(), + [OpEventCode.SCHEDULE_LOCK] = capabilities.lock.lock.locked(), + [OpEventCode.SCHEDULE_UNLOCK] = capabilities.lock.lock.unlocked() + } + local lock_event = OP_EVENT_CODE_CAPABILITY_MAP[op_event_code] + if not lock_event then return end + lock_event.data = {} + + -- get method of lock event + local OpEventSource = clusters.DoorLock.types.DrlkOperationEventSource + local OP_EVENT_SOURCE_CAPABILITY_MAP = { + [OpEventSource.KEYPAD] = "keypad", + [OpEventSource.RF] = "command", + [OpEventSource.MANUAL] = "manual", + [OpEventSource.RFID] = "rfid", + -- These last two sources are not found in the spec, but they were in + -- the legacy driver and appear to be related to the Samsung SDS + [4] = "fingerprint", + [5] = "bluetooth", + } + if (op_event_source ~= OpEventSource.KEYPAD and ( + op_event_code == OpEventCode.AUTO_LOCK or + op_event_code == OpEventCode.SCHEDULE_LOCK or + op_event_code == OpEventCode.SCHEDULE_UNLOCK + )) then + lock_event.data.method = "auto" + else + lock_event.data.method = OP_EVENT_SOURCE_CAPABILITY_MAP[op_event_source] or "manual" + end + + -- get stored lockUsers data if applicable + if op_event_source == OpEventSource.KEYPAD and device:supports_capability(capabilities.lockUsers) then + local user_id = tonumber(zb_rx.body.zcl_body.user_id.value) + local associated_user = tables.find_entry(device, "users", user_id) + if associated_user then + lock_event.data.userIndex = user_id .. "" + lock_event.data.userName = associated_user.userName + lock_event.data.userType = associated_user.userType + else + lock_event.data.userIndex = user_id .. "" + lock_event.data.userName = "Guest " .. user_id -- default + end + end + + -- if this is an event corresponding to a recently-received attribute report, we + -- want to set our delay timer for future lock attribute report events + local endpoint_id = zb_rx.address_header.src_endpoint.value + if lock_event.value.value == device:get_latest_state( + device:get_component_id_for_endpoint(endpoint_id), + capabilities.lock.ID, + capabilities.lock.lock.ID + ) then + local preceding_event_time = device:get_field(consts.DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < consts.MAX_DELAY then + device:set_field(consts.DELAY_LOCK_EVENT, time_diff) + end + end + + device:emit_event_for_endpoint(endpoint_id, lock_event) end local samsung_sds_driver = { @@ -89,7 +158,8 @@ local samsung_sds_driver = { zigbee_handlers = { cluster = { [DoorLock.ID] = { - [SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND] = mfg_lock_door_handler + [SAMSUNG_SDS_MFR_SPECIFIC_UNLOCK_COMMAND] = mfg_lock_door_handler, + [clusters.DoorLock.client.commands.OperatingEventNotification.ID] = operating_event_notification, } }, attr = { @@ -112,9 +182,7 @@ local samsung_sds_driver = { added = device_added, init = device_init }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "SAMSUNG SDS" - end + can_handle = require("samsungsds.can_handle"), } return samsung_sds_driver diff --git a/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua new file mode 100644 index 0000000000..d3491ed664 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("legacy-handlers"), + lazy_load_if_possible("samsungsds"), + lazy_load_if_possible("bad-battery-reporter"), + lazy_load_if_possible("yale-fingerprint-lock"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua b/drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua similarity index 54% rename from drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua index d499d7ff66..6dfd87d155 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_yale_fingerprint_bad_battery_reporter.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_bad_battery_reporter.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -55,7 +44,48 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) } + }, + { + min_api_version = 14 } ) +local mock_device_yrd = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints ={ + [1] = { + id = 1, + manufacturer ="Yale", + model ="YRD220/240 TSDB", + server_clusters = {} + } + } +}) + +local function test_init_yrd() + test.mock_device.add_test_device(mock_device_yrd) +end + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device_yrd.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device_yrd, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device_yrd:generate_test_message("main", capabilities.battery.battery(55)) + } + }, + { + test_init = test_init_yrd, + min_api_version = 14 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_c2o_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_c2o_lock.lua index b6fa3d1323..bf358af78c 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_c2o_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_c2o_lock.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -50,7 +39,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -81,7 +73,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -91,7 +86,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +99,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "lock", component = "main", command = "lock", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.commands.LockDoor(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -111,7 +112,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "lock", component = "main", command = "unlock", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.commands.UnlockDoor(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -123,7 +127,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(5) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -135,14 +142,20 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(5) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "PinUsersSupported report should be a no-op", function () test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 8)}) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua similarity index 51% rename from drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua index ed4ce6e3cc..bd15eb3dd8 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_generic_lock_migration.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_generic_fingerprint_profile_update.lua @@ -1,22 +1,12 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock @@ -42,7 +32,22 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device)}) test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 8)}) mock_device:expect_metadata_update({profile = "base-lock"}) - end + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") })) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + -- inject_capability_command(refresh) is called inline by info_changed + test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.LockState:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, Alarm.attributes.AlarmCount:read(mock_device)}) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua new file mode 100644 index 0000000000..1ff448a719 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_init_lifecycle_handlers.lua @@ -0,0 +1,484 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the four lifecycle handlers defined in init.lua: +-- added (device_added), doConfigure (do_configure), +-- infoChanged (info_changed), init (LockLifecycle.init) + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration +local Alarms = clusters.Alarms +local constants = require "lock_utils.constants" + +-- ── Shared mock devices ──────────────────────────────────────────────────── +-- base-lock profile: lock + lockCodes + lockCredentials + lockUsers + battery +local mock_device_base = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +-- Same profile but provisioning_state = "TYPED" (freshly fingerprinted) +local mock_device_typed = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + _provisioning_state = "TYPED", +}) + +-- lock-battery profile: lock + battery only (no lockCodes / lockCredentials) +local mock_device_battery = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +-- Helper: make a test_init function that suppresses startup messages and +-- registers the given device. +local function make_test_init(device) + return function() + test.disable_startup_messages() + test.mock_device.add_test_device(device) + end +end + +-- Helper: expect the five zigbee messages produced by sync_device_state on a +-- freshly-started device (no cached capability state, CODE_INDEX starts at 1). +local function expect_sync_device_state(device) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.SendPINOverTheAir:write(device, true) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 1) }) +end + +-- Helper: expect the messages produced by the legacy reload_all_codes path after +-- the 2-second doConfigure timer fires on a non-SLGA_MIGRATED device. +-- Unlike sync_device_state, reload_all_codes emits scanCodes("Scanning") and +-- starts iterating from code slot 0 (CHECKING_CODE = 0). +local function expect_reload_all_codes_messages(device) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.SendPINOverTheAir:write(device, true) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.MaxPINCodeLength:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.MinPINCodeLength:read(device) }) + test.socket.zigbee:__expect_send({ device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(device) }) + test.socket.capability:__expect_send( + device:generate_test_message("main", + capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } })) + ) + test.socket.zigbee:__expect_send({ device.id, DoorLock.server.commands.GetPINCode(device, 0) }) +end + +-- Helper: expect the six zigbee messages sent by do_configure for any device. +local function expect_do_configure_zigbee(device) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 600, 21600, 1), + }) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, DoorLock.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + DoorLock.attributes.LockState:configure_reporting(device, 0, 3600, 0), + }) + test.socket.zigbee:__expect_send({ + device.id, + zigbee_test_utils.build_bind_request(device, zigbee_test_utils.mock_hub_eui, Alarms.ID), + }) + test.socket.zigbee:__expect_send({ + device.id, + Alarms.attributes.AlarmCount:configure_reporting(device, 0, 21600, 0), + }) +end + +-- ============================================================================ +-- added (device_added) +-- ============================================================================ + +test.register_coroutine_test( + "added: TYPED device with lockCodes emits migrated event, persists SLGA_MIGRATED, and injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + + -- Migrated event is emitted before the injected refresh + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler inline + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.LockState:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + Alarms.attributes.AlarmCount:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.MaxPINCodeLength:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.MinPINCodeLength:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_typed), + }) + test.wait_for_events() + + assert( + mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be true after added fires for a TYPED device" + ) + end, + { test_init = make_test_init(mock_device_typed) } +) + +test.register_coroutine_test( + "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "added" }) + + -- No migrated capability event expected + test.socket.zigbee:__expect_send({ + mock_device_base.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_base), + }) + test.socket.zigbee:__expect_send({ + mock_device_base.id, + DoorLock.attributes.LockState:read(mock_device_base), + }) + test.socket.zigbee:__expect_send({ + mock_device_base.id, + Alarms.attributes.AlarmCount:read(mock_device_base), + }) + test.wait_for_events() + + assert( + mock_device_base:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) ~= true, + "SLGA_MIGRATED must NOT be set for a non-TYPED device" + ) + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "added: device without lockCodes does NOT emit migrated event but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + + -- No migrated capability event expected. + -- For non-SLGA_MIGRATED devices the legacy-handlers refresh fires, which reads + -- NumberOfPINUsersSupported when the device has no lockCodes and no cached code support. + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.LockState:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + Alarms.attributes.AlarmCount:read(mock_device_battery), + }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_battery), + }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- doConfigure (do_configure) +-- ============================================================================ + +test.register_coroutine_test( + "doConfigure: SLGA_MIGRATED device with lockCredentials sends bind/configure then calls sync_device_state after 2-second delay", + function() + -- Pre-seed SLGA_MIGRATED so the main driver's do_configure fires (not legacy-handlers). + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_base) + mock_device_base:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + -- Timer fires: sync_device_state is called (GetPINCode slot 1) + test.mock_time.advance_time(2) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_sync_device_state(mock_device_base) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "doConfigure: SLGA_MIGRATED device without lockCredentials sends bind/configure but does NOT create sync timer", + function() + -- Pre-seed SLGA_MIGRATED so the main driver's do_configure fires. + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_battery) + mock_device_battery:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + -- lock-battery has no lockCredentials, so no 2-second timer is created. + end, + { test_init = make_test_init(mock_device_battery) } +) + +test.register_coroutine_test( + "doConfigure: non-SLGA_MIGRATED device triggers legacy reloadAllCodes (with scanCodes emit) after 2-second delay", + function() + -- mock_device_typed has no SLGA_MIGRATED → legacy-handlers' do_configure fires, + -- which injects a reloadAllCodes capability command after a 2-second delay. + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "doConfigure" }) + + expect_do_configure_zigbee(mock_device_typed) + mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + -- Timer fires: legacy reload_all_codes runs, iterating from code slot 0. + test.mock_time.advance_time(2) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_reload_all_codes_messages(mock_device_typed) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_typed) } +) + +-- ============================================================================ +-- infoChanged (info_changed) +-- Each test uses a per-test fresh device (upvalue pattern) to avoid +-- raw_st_data contamination from generate_info_changed across tests. +-- init is triggered first so the driver loads the device into device_cache, +-- allowing infoChanged to correctly identify the old profile. +-- ============================================================================ + +do + local dev + test.register_coroutine_test( + "infoChanged: switching from non-lockCodes to lockCodes+lockCredentials profile triggers full SLGA migration and two syncs", + function() + -- Warm up device_cache with the original (lock-battery) profile via init. + -- For a non-SLGA_MIGRATED device, legacy-handlers' init fires (no zigbee sends). + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- Switch to base-lock (lockCodes + lockCredentials) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.device_lifecycle:__queue_receive( + dev:generate_info_changed({ profile = t_utils.get_profile_definition("base-lock.yml") }) + ) + -- Migration events + test.socket.capability:__expect_send( + dev:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + dev:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler inline + test.socket.zigbee:__expect_send({ + dev.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + DoorLock.attributes.LockState:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + Alarms.attributes.AlarmCount:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + DoorLock.attributes.MaxPINCodeLength:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + DoorLock.attributes.MinPINCodeLength:read(dev), + }) + test.socket.zigbee:__expect_send({ + dev.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(dev), + }) + -- Immediate sync_device_state + test.wait_for_events() + + assert( + dev:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, + "SLGA_MIGRATED must be set after infoChanged profile switch" + ) + + -- Delayed sync_device_state (2 s) + test.mock_time.advance_time(2) + test.socket.zigbee:__set_channel_ordering("relaxed") + expect_sync_device_state(dev) + test.wait_for_events() + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + +do + local dev + test.register_coroutine_test( + "infoChanged: no profile change does nothing", + function() + -- Warm up device_cache + test.socket.device_lifecycle:__queue_receive({ dev.id, "init" }) + test.wait_for_events() + + -- infoChanged with no profile change + test.socket.device_lifecycle:__queue_receive(dev:generate_info_changed({})) + test.wait_for_events() + -- No capability events, no zigbee sends expected + end, + { + test_init = function() + test.disable_startup_messages() + dev = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + }) + test.mock_device.add_test_device(dev) + end, + } + ) +end + + +-- ============================================================================ +-- init (LockLifecycle.init) +-- ============================================================================ + +test.register_coroutine_test( + "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: device with lockCodes but SLGA_MIGRATED not set does nothing", + function() + -- SLGA_MIGRATED is not set; lockCodes is supported; elseif branch does not apply + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.wait_for_events() + -- No capability events and no zigbee sends expected + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: SLGA_MIGRATED device without lockCodes sends NumberOfPINUsersSupported read to detect re-profiling", + function() + -- Pre-seed SLGA_MIGRATED=true so legacy-handlers is bypassed and the main driver's + -- init fires. lock-battery has no lockCodes, so the elseif branch executes and + -- reads NumberOfPINUsersSupported to detect whether the device should be re-profiled. + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.socket.zigbee:__expect_send({ + mock_device_battery.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_battery), + }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- driverSwitched (LockLifecycle.driver_switched) +-- ============================================================================ + +test.register_coroutine_test( + "driver_switched: device with lockCodes and migrated=true persists SLGA_MIGRATED and updates metadata", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "added" }) + + -- Migrated event is emitted before the injected refresh + test.socket.capability:__expect_send( + mock_device_typed:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + -- inject_capability_command calls the refresh handler inline + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.LockState:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + Alarms.attributes.AlarmCount:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.MaxPINCodeLength:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.MinPINCodeLength:read(mock_device_typed), + }) + test.socket.zigbee:__expect_send({ + mock_device_typed.id, + DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device_typed), + }) + test.wait_for_events() + + -- driverSwitched occurs after added, so migrated=true is already set in the capability state cache + test.socket.device_lifecycle:__queue_receive({ mock_device_typed.id, "driver_switched" }) + mock_device_typed:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + assert(mock_device_typed:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true) + end, + { test_init = make_test_init(mock_device_typed) } +) + +-- ============================================================================ +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua new file mode 100644 index 0000000000..e29788fe36 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_code_slga_migration.lua @@ -0,0 +1,76 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local PowerConfiguration = clusters.PowerConfiguration +local Alarm = clusters.Alarms +local capabilities = require "st.capabilities" +local constants = require "lock_utils.constants" + +local json = require "st.json" + +local mock_datastore = require "integration_test.mock_env_datastore" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + data = { + lockCodes = json.encode({ + ["1"] = "Zach", + ["5"] = "Steven" + }), + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device called 'migrate' command", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + -- Validate lockCodes field + mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["5"] = "Steven" }) + -- Validate migration complete flag + mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) + -- Set min/max code length attributes + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 5) }) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 10) }) + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + -- Validate `migrate` command functionality. + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(5, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(10, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=5, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1, credentialName="Zach"}, {credentialIndex=5, credentialType="pin", userIndex=5, credentialName="Steven"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) + test.wait_for_events() + assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "SLGA_MIGRATED field should be set to true after migration") + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua new file mode 100644 index 0000000000..33293e784d --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_credentials_commands.lua @@ -0,0 +1,578 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockCredentials capability commands: +-- addCredential, updateCredential, deleteCredential, deleteAllCredentials + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS) + end + test.wait_for_events() +end + +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS) + end + test.wait_for_events() +end + +-- ============================================================================ +-- addCredential +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: sends SetPINCode to the lock and emits success when the lock acknowledges", + function() + -- Queue the capability command: userIndex=1, userType="guest", credentialType="pin", credentialData="1234" + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + -- Expect SetPINCode to be sent to the lock + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + -- Lock responds with SUCCESS + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + -- Handler adds the credential and user to the credentials table + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + -- commandResult with userIndex and credentialIndex + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits failure when the lock returns GENERAL_FAILURE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.GENERAL_FAILURE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits resourceExhausted when the lock returns MEMORY_FULL", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.MEMORY_FULL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: emits duplicate when the lock returns DUPLICATE_CODE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.DUPLICATE_CODE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential: returns busy when another operation is already in progress", + function() + -- Put the device into busy state by starting an addCredential operation + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + -- Second addCredential while first is still pending → should get busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: sends SetPINCode and emits success when the lock acknowledges", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin9" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "newPin9"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns failure immediately when the credential does not exist in the table", + function() + -- No credentials seeded — credentialIndex 99 does not exist + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + -- No zigbee message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- Start first updateCredential to make device busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin1111" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin1111"), + }) + test.wait_for_events() + + -- Second updateCredential while first is pending + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin2222" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential: sends ClearPINCode and emits success with indices when the lock returns PASS", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- credential table and user should be deleted + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: emits failure when the lock returns FAIL", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns failure immediately when the credentialIndex does not exist in the table", + function() + -- No credentials seeded — index 5 is unknown + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 5, "pin" } }, + }) + + -- No zigbee messages should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential: returns busy when another operation is already in progress", + function() + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + -- Start first deleteCredential to make device busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + -- Second deleteCredential while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 2, "pin" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllCredentials +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials: sends ClearAllPINCodes and emits success when the lock returns PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- deleteAllCredentials only clears credentials; users table is untouched + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: emits failure when the lock returns FAIL", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- First deleteAllCredentials starts the zigbee flow (device is now busy) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + -- Second deleteAllCredentials while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua new file mode 100644 index 0000000000..0eb4ac396f --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_pre_configured.lua @@ -0,0 +1,653 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for lockUsers and lockCredentials commands with state +-- pre-configured before each test. Two users and two credentials are seeded +-- at the start of every test so tests can focus on the various response states +-- produced by the zigbee response handlers in commands.lua. + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +-- ── Shared device ────────────────────────────────────────────────────────── + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- ── Seeding helpers ──────────────────────────────────────────────────────── + +local INITIAL_USERS = { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, +} +local INITIAL_CREDS = { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, +} + +-- Seed a list of entries into a named table, consuming the resulting events. +local function seed_table(attribute_fn, table_name, entries) + local accumulated = {} + for _, entry in ipairs(entries) do + table.insert(accumulated, entry) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + attribute_fn(accumulated, { visibility = { displayed = false } })) + ) + assert( + table_utils.add_entry(mock_device, table_name, entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_table: add_entry failed for " .. table_name + ) + end + test.wait_for_events() +end + +-- Pre-configure each test with 2 users and 2 credentials. +local function setup_state() + seed_table(capabilities.lockUsers.users, "users", INITIAL_USERS) + seed_table(capabilities.lockCredentials.credentials, "credentials", INITIAL_CREDS) +end + +-- ============================================================================ +-- addUser — pre-configured device (2 users already present) +-- ============================================================================ + +test.register_coroutine_test( + "addUser (pre-configured): assigns the next available index (3) when two users already exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser — pre-configured device +-- ============================================================================ + +test.register_coroutine_test( + "updateUser (pre-configured): updates Alice's name and emits success with userIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceRenamed", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceRenamed", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + + +-- ============================================================================ +-- deleteUser with associated credential — exercises clear_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser (pre-configured, PASS): removes both user and credential and emits success for each", + function() + setup_state() + + -- Delete user 1 who has credential 1 → driver injects deleteCredential → ClearPINCode + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteUser (pre-configured, FAIL): emits failure for both capabilities when the lock rejects", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- addCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "addCredential (pre-configured): succeeds for a new slot and emits userIndex + credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Guest 3", userType = "guest" }, -- default name since lock doesn't provide one + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 3" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 3, credentialIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits duplicate when the lock rejects with DUPLICATE_CODE", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin01" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin01"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.DUPLICATE_CODE), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addCredential (pre-configured): emits resourceExhausted when the lock returns MEMORY_FULL", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "pin03"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.MEMORY_FULL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential (pre-configured): succeeds and emits success with userIndex and credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin1" } }, + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "newPin1"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateCredential (pre-configured): returns failure immediately for a non-existent credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteCredential standalone (lockCredentials.DELETE path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential (pre-configured, PASS): removes credential and emits success with indices", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteCredential (pre-configured, FAIL): emits failure when the lock rejects the clear", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 2, "pin" } }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllCredentials standalone (lockCredentials.DELETE_ALL path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials (pre-configured, PASS): clears only credentials and emits success", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + -- Only credentials table is cleared; users table is untouched (no lockUsers.users event) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllCredentials (pre-configured, FAIL): emits failure and leaves tables unchanged", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllUsers (lockUsers.DELETE_ALL path — clears both tables) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers (pre-configured, PASS): clears both tables and emits success for both capabilities", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllUsers (pre-configured, FAIL): emits failure for both capabilities when the lock rejects", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua new file mode 100644 index 0000000000..3c06afef66 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_programming_events.lua @@ -0,0 +1,574 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Tests for the ProgrammingEventNotification handler in lock_handlers/zigbee_responses.lua. +-- +-- Cases covered: +-- • PIN_CODE_ADDED received while NOT busy (manual addition at the lock) +-- • PIN_CODE_DELETED received while NOT busy (manual deletion at the lock) +-- • PIN_CODE_CHANGED received while NOT busy (manual update — not currently handled) +-- • PIN_CODE_ADDED while addCredential in flight with matching user (failsafe path) +-- • PIN_CODE_CHANGED while updateCredential in flight with matching user (failsafe path) +-- • PIN_CODE_DELETED while deleteCredential in flight with matching user (failsafe path) +-- • PIN_CODE_ADDED while busy with DIFFERENT user (processed as manual event) +-- • PIN_CODE_DELETED while busy with DIFFERENT user (processed as manual event) +-- • PIN_CODE_ADDED received after BUSY ends (late notification from our SetPINCode; credential +-- not double-added) +-- • PIN_CODE_ADDED received after BUSY ends (both entries already exist; complete no-op) +-- • PIN_CODE_DELETED received after BUSY ends (late notification from our ClearPINCode; credential +-- already deleted) +-- • PIN_CODE_CHANGED received after BUSY ends (late notification from our SetPINCode update; +-- not handled, no effect) + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local capabilities = require "st.capabilities" +local constants = require "lock_utils.constants" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local ProgrammingEventCode = DoorLock.types.ProgramEventCode +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +-- Build a ProgrammingEventNotification ZigBee receive message for the given event code and user ID. +local function build_programming_event(event_code, user_id) + return { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x00, -- program_event_source (keypad) + event_code, + user_id, + "1234", -- PIN (not used by the handler) + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, -- local_alarm_mask + "data" -- user_description + ) + } +end + +-- ───────────────────────────────────────────────────────────────────────────── +-- NOT-BUSY CASES (manual events from the lock, no command in flight) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED while not busy syncs user and credential entries", + function() + -- Route events to the new capabilities handler, not the legacy lockCodes handler. + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + -- Users table receives the new entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + -- Credentials table receives the new entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED while not busy removes user and credential entries", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Set up an existing entry by processing a manual addition first. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Now delete the same entry via a manual deletion event. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_CHANGED while not busy has no effect on table state", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- PIN_CODE_CHANGED is not handled by the notification handler, so no events should + -- be emitted and table state should remain unchanged. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- BUSY CASES (notification arrives while one of our commands is in flight) +-- The handler must be a no-op so we do not double-process the result. +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED while addCredential is in flight acts as failsafe", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an addCredential command being in flight with matching user ID. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + -- Failsafe path: notification handled as command success, user and credential added, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_CHANGED while updateCredential is in flight acts as failsafe", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an updateCredential command being in flight with matching user ID. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.UPDATE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + -- Failsafe path: notification handled as command success, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED while deleteCredential is in flight acts as failsafe", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- First, add a credential so we have something to delete via the failsafe path. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Simulate a deleteCredential command being in flight with matching user ID. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.DELETE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + -- Failsafe path: notification handled as command success, credential and user removed, commandResult emitted. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- BUSY WITH DIFFERENT USER (notification for a different user arrives while +-- a command is in flight; processed as a normal manual event) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_ADDED for different user while busy is processed as manual event", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Simulate an addCredential command in flight for user 1. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + -- Notification arrives for user 2 (different user) — should be processed as manual event. + -- The manual handler assigns userIndex = next_index (1, the first available slot), not user_id (2). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "ProgrammingEventNotification PIN_CODE_DELETED for different user while busy is processed as manual event", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- First, add a credential for user 2 so we have something to delete. + -- The manual handler assigns userIndex = next_index (1, the first available slot), not user_id (2). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + ) + ) + ) + test.wait_for_events() + + -- Simulate a deleteCredential command in flight for user 1. + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.DELETE, {}) + mock_device:set_field(constants.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, { + userIndex = 1, credentialIndex = 1, credentialType = "pin" + }, {}) + + -- Notification arrives for user 2 (different user) — should be processed as manual deletion. + -- Delete handler emits credentials first, then users. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 2)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +-- ───────────────────────────────────────────────────────────────────────────── +-- POST-BUSY CASES (busy state was already cleared by the ZigBee response +-- handler before the ProgrammingEventNotification arrives; the notification was +-- sent by the lock in response to our own SetPINCode / ClearPINCode command) +-- ───────────────────────────────────────────────────────────────────────────── + +test.register_coroutine_test( + "Late PIN_CODE_ADDED after addCredential: user and credential already exist; complete no-op", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Complete an addCredential flow so both the user and credentials tables already have entries. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- User and credential added; commandResult emitted; busy state cleared. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late ProgrammingEventNotification PIN_CODE_ADDED for the same slot arrives after busy ends. + -- The credential is already in the table → find_entry returns it → handler exits early. + -- Both user and credential already exist, so no events are emitted. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_ADDED when both user and credential already exist: both add_entry calls are no-ops", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Populate both tables by processing a not-busy PIN_CODE_ADDED (simulates the completed command state). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late notification for the same slot; both add_entry calls return OCCUPIED → no events. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_DELETED after deleteCredential: credential and user already removed; no-op", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Populate both tables (user at index 1, credential at index 1). + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Run a standalone deleteCredential flow; this removes the credential but leaves the user. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + -- Credential and user entries removed (no remaining credentials for this user). Busy state cleared. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late PIN_CODE_DELETED for the same slot arrives after busy ends. + -- The credential and user are already gone → therefore nothing happens. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_DELETED, 1)) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "Late PIN_CODE_CHANGED after updateCredential: not handled by notification handler, no events emitted", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Seed user and credential so updateCredential has an existing entry to modify. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_ADDED, 1)) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Complete an updateCredential flow so the credential entry is updated and busy state is cleared. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "5678" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "5678"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- UPDATE doesn't modify the credentials table metadata, only the PIN code (not stored). + -- The response handler just emits commandResult; busy state is cleared. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Late PIN_CODE_CHANGED notification arrives after busy ends. + -- The notification handler does not handle PIN_CODE_CHANGED → no events emitted. + test.socket.zigbee:__queue_receive(build_programming_event(ProgrammingEventCode.PIN_CODE_CHANGED, 1)) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua new file mode 100644 index 0000000000..82ecd7f964 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_tables.lua @@ -0,0 +1,728 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Unit tests for lock_utils/tables.lua +-- Tests directly call table_utils functions and verify both return values +-- and emitted capability events. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +-- --------------------------------------------------------------------------- +-- Shared mock device +-- --------------------------------------------------------------------------- + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- Seed the users table with `entries` and consume the resulting emit_events. +-- After this call the state cache has those entries and the socket is clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + -- Build the expected post-insert table up to this entry. + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for entry userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Seed the credentials table with `entries` and consume the resulting emit_events. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for entry credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- =========================================================================== +-- add_entry +-- =========================================================================== + +test.register_coroutine_test( + "add_entry: adds a new user entry and emits the updated table", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "add_entry: returns OCCUPIED when an entry with the same userIndex already exists", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- Attempt to add a different entry with the same userIndex (match_key) + local duplicate = { userIndex = 1, userType = "unrestricted", userName = "Bob" } + local result = table_utils.add_entry(mock_device, "users", duplicate) + assert(result == constants.COMMAND_RESULT.OCCUPIED, + "Expected OCCUPIED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns RESOURCE_EXHAUSTED when table is at max capacity", + function() + local entries = {} + for i = 1, 20 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local overflow = { userIndex = 21, userType = "guest", userName = "Overflow" } + local result = table_utils.add_entry(mock_device, "users", overflow) + assert(result == constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, + "Expected RESOURCE_EXHAUSTED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE when a required key is missing", + function() + -- userType is required for users table + local incomplete = { userIndex = 1 } + local result = table_utils.add_entry(mock_device, "users", incomplete) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.add_entry(mock_device, "nonexistent_table", + { userIndex = 1, userType = "guest" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: adds a credential entry and emits updated credentials table", + function() + local entry = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +-- =========================================================================== +-- update_entry +-- =========================================================================== + +test.register_coroutine_test( + "update_entry: updates an existing user entry and emits updated table", + function() + local original = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ original }) + + local expected_after_update = { + { userIndex = 1, userType = "adminMember", userName = "Alice_Updated" } + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_after_update, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 1, + { userName = "Alice_Updated", userType = "adminMember" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.update_entry(mock_device, "users", 99, + { userName = "Ghost" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: only updates the specified fields, leaves others intact", + function() + local entry1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local entry2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ entry1, entry2 }) + + -- Update only userName of entry 2; userType should remain "guest" + local expected = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 2, userType = "guest", userName = "Bob_Renamed" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 2, { userName = "Bob_Renamed" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.update_entry(mock_device, "bad_table", 1, { userName = "X" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_entry +-- =========================================================================== + +test.register_coroutine_test( + "delete_entry: deletes an existing entry and returns COMMAND_RESULT.SUCCESS", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- After deletion the table is empty + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 1) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.delete_entry(mock_device, "users", 99) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: remaining entries are intact after a deletion", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + -- Delete the middle entry; expect e1 and e3 remain + local expected_remaining = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 3, userType = "guest", userName = "Carol" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_remaining, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 2) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_entry(mock_device, "bad_table", 1) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_all_entries +-- =========================================================================== + +test.register_coroutine_test( + "delete_all_entries: emits an empty users table and returns SUCCESS", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_all_entries: returns SUCCESS even when the table is already empty", + function() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "delete_all_entries: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_all_entries(mock_device, "bad_table") + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: emits an empty credentials table and returns SUCCESS", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + seed_credentials({ cred }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "credentials") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + + test.wait_for_events() + end +) + +-- =========================================================================== +-- find_entry +-- =========================================================================== + +test.register_coroutine_test( + "find_entry: returns the matching entry when it exists", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry(mock_device, "users", 2) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + assert(result.userName == "Bob", + "Expected userName == 'Bob', got: " .. tostring(result.userName)) + end +) + +test.register_coroutine_test( + "find_entry: returns nil when no entry matches the value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry(mock_device, "users", 99) + assert(result == nil, + "Expected nil for missing entry, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry: finds a credential entry by credentialIndex", + function() + local cred = { userIndex = 1, credentialIndex = 5, credentialType = "pin" } + seed_credentials({ cred }) + + local result = table_utils.find_entry(mock_device, "credentials", 5) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.credentialIndex == 5, + "Expected credentialIndex == 5, got: " .. tostring(result.credentialIndex)) + end +) + +-- =========================================================================== +-- find_entry_by +-- =========================================================================== + +test.register_coroutine_test( + "find_entry_by: returns the entry matching an arbitrary key", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "adminMember", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Bob") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns nil when no entry matches", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Nobody") + assert(result == nil, + "Expected nil for unmatched key/value, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns the first matching entry when multiple entries share the same value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userType", "guest") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 1, + "Expected first match userIndex == 1, got: " .. tostring(result.userIndex)) + end +) + +-- =========================================================================== +-- next_index +-- =========================================================================== + +test.register_coroutine_test( + "next_index: returns 1 when the table is empty", + function() + local result = table_utils.next_index(mock_device, "users") + assert(result == 1, + "Expected 1 for empty table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "next_index: returns the next sequential index after a contiguous range", + function() + local entries = {} + for i = 1, 3 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 4, + "Expected 4 after indices 1-3, got: " .. tostring(result)) + end +) + +-- this should not happen during normal operation. +test.register_coroutine_test( + "next_index: returns the lowest gap when indices are non-contiguous", + function() + -- Insert entries at indices 1 and 3, leaving a gap at 2 + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e3 }) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 2, + "Expected 2 as the lowest gap, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- get_max_entries +-- =========================================================================== + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "users") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 for the credentials table when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "credentials") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- Persistence — mutations write to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: add_entry writes the new users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "users", entry) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 1 and persisted[1].userName == "Alice", + "Persisted user data does not match the added entry") + end +) + +test.register_coroutine_test( + "persist: update_entry writes the updated users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userType = "adminMember", userName = "Alice" } }, + { visibility = { displayed = false } })) + ) + table_utils.update_entry(mock_device, "users", 1, { userType = "adminMember" }) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after update") + assert(persisted[1].userType == "adminMember", + "Persisted user type was not updated, got: " .. tostring(persisted[1].userType)) + end +) + +test.register_coroutine_test( + "persist: delete_entry removes the entry from the persistent store immediately", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ e2 }, { visibility = { displayed = false } })) + ) + table_utils.delete_entry(mock_device, "users", 1) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after delete, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 2, + "Expected remaining user to have userIndex == 2, got: " .. tostring(persisted[1].userIndex)) + end +) + +test.register_coroutine_test( + "persist: delete_all_entries writes an empty table to the persistent store", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + table_utils.delete_all_entries(mock_device, "users") + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 0, + "Expected empty persistent store after delete_all, got: " .. tostring(persisted and #persisted)) + end +) + +test.register_coroutine_test( + "persist: add_entry writes the new credentials table to the persistent store immediately", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "credentials", cred) + test.wait_for_events() + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedCredentials")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted credential, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].credentialIndex == 1, + "Persisted credential index does not match") + end +) + +-- =========================================================================== +-- Persistence — get_state falls back to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: get_state returns data from the persistent store when capability state cache is absent", + function() + -- The device was loaded with a pre-seeded persistent field (set in test_init + -- below), but no capability event has been emitted for users, so get_latest_state + -- returns nil. get_state must fall back to the persistent store. + local state = table_utils.get_state(mock_device, "users") + assert(type(state) == "table" and #state == 1, + "Expected 1 user from persistent-store fallback, got: " .. tostring(state and #state)) + assert(state[1].userIndex == 1 and state[1].userName == "Alice", + "Fallback data does not match pre-seeded persistent entry") + end, + { + test_init = function() + -- Pre-seed persistent store BEFORE add_test_device so that wrapped_init + -- copies the field into the device's persistent_store on startup. + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +-- =========================================================================== +-- Persistence — restore_from_persistent_store re-emits stored tables +-- =========================================================================== + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits users capability event for stored data", + function() + local user = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ user }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end, + { + test_init = function() + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits credentials capability event for stored data", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end, + { + test_init = function() + mock_device:set_field( + "persistedCredentials", + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store is a no-op when the persistent store is empty", + function() + -- No capability events expected when there is nothing in the persistent store. + table_utils.restore_from_persistent_store(mock_device) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua new file mode 100644 index 0000000000..2f68f0cfb6 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_lock_users_commands.lua @@ -0,0 +1,654 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockUsers capability commands: +-- addUser, updateUser, deleteUser, deleteAllUsers + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) +zigbee_test_utils.prepare_zigbee_env_info() + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- Directly insert users into the device state via table_utils (mirrors test_lock_tables.lua). +-- Consumes the resulting capability events so the socket queue stays clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Directly insert credentials into the device state. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- Set totalUsersSupported on the mock device and consume the resulting event. +local function set_total_users_supported(n) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(n, { visibility = { displayed = false } })) + ) + mock_device:emit_event(capabilities.lockUsers.totalUsersSupported(n, { visibility = { displayed = false } })) + test.wait_for_events() +end + +-- ============================================================================ +-- addUser +-- ============================================================================ + +test.register_coroutine_test( + "addUser: assigns userIndex 1 for the first user and emits a success commandResult", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Alice", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: assigns the next sequential userIndex when users already exist", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: fills a gap left by a deleted user rather than appending beyond max", + function() + -- Seed indices 1 and 3; index 2 is the expected gap to fill + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "addUser: returns resourceExhausted when totalUsersSupported has been reached", + function() + set_total_users_supported(2) + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- updateUser +-- ============================================================================ + +test.register_coroutine_test( + "updateUser: updates an existing user and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceUpdated", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceUpdated", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: adds user and returns success when the target userIndex does not exist", + function() + -- empty table, add user + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 99, "Ghost", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 99, userName = "Ghost", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 99 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "updateUser: can change a user's type as well as name", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Alice", "adminMember" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "adminMember" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteUser (no associated credential — pure local delete) +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser: removes a user with no credential and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteUser: returns failure when the target userIndex is not in the users table", + function() + -- empty table + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 99 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- deleteAllUsers (injects deleteAllCredentials → ClearAllPINCodes zigbee flow) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers: sends ClearAllPINCodes and emits success for both users and credentials on PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + -- deleteAllUsers injects deleteAllCredentials, which sends ClearAllPINCodes + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + local ResponseStatus = DoorLock.types.DrlkPassFailStatus + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "deleteAllUsers: emits failure for both users and credentials when the lock returns FAIL", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearAllPINCodes(mock_device) }) + test.wait_for_events() + + local ResponseStatus = DoorLock.types.DrlkPassFailStatus + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearAllPINCodesResponse.build_test_rx(mock_device, ResponseStatus.FAIL), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- State-consistency: add → delete → re-add (indices 1, 2, 3 lifecycle) +-- ============================================================================ +-- +-- These tests verify that the users and credentials tables stay in sync +-- through a full lifecycle: populate three slots, remove the middle one, +-- then re-add a user and credential into the freed slot. The goal is to +-- confirm there is no stale index state that would cause duplicate entries, +-- wrong slot assignment, or mismatched user↔credential links. + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local SetCodeStatus = DoorLock.types.DrlkSetCodeStatus +local ResponseStatus = DoorLock.types.DrlkPassFailStatus + +test.register_coroutine_test( + "State-consistency: add users 1-3, deleteUser 2 (no credential), re-add user reclaims index 2", + function() + -- Populate three user slots directly (no credentials, so deleteUser will take + -- the no-ZigBee path and delete the user entry locally). + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + -- Delete user at index 2; no credential is linked so this is a pure local delete. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3}, so it assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "State-consistency: add users+credentials 1-3, deleteUser 2 (with credential), re-add user+credential reclaims index 2 cleanly", + function() + -- Populate three user and credential slots. + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }) + + -- Delete user at index 2. The handler finds a linked credential and injects + -- deleteCredential, which sends ClearPINCode to the lock. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 2) }) + test.wait_for_events() + + -- Lock acknowledges the deletion. + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.ClearPINCodeResponse.build_test_rx(mock_device, ResponseStatus.PASS), + }) + -- clear_pin_code_response (LOCK_USERS.DELETE path): deletes credentials first, then user. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 2, userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3} and assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a credential for the new user at slot 2. The old credential at + -- credentialIndex 2 was cleanly removed, so add_entry must succeed without + -- returning OCCUPIED or any stale-state error. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "9999" } }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "9999"), + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.SetPINCodeResponse.build_test_rx(mock_device, SetCodeStatus.SUCCESS), + }) + -- Credential is freshly added at index 2 alongside the retained entries at 1 and 3. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 2, credentialIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua deleted file mode 100644 index 1aa9432933..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_code_migration.lua +++ /dev/null @@ -1,244 +0,0 @@ --- Copyright 2022 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. - --- Mock out globals -local test = require "integration_test" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" -local t_utils = require "integration_test.utils" - -local clusters = require "st.zigbee.zcl.clusters" -local PowerConfiguration = clusters.PowerConfiguration -local DoorLock = clusters.DoorLock -local Alarm = clusters.Alarms - -local json = require "st.json" - -local mock_datastore = require "integration_test.mock_env_datastore" - -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - data = { - lockCodes = json.encode({ - ["1"] = "Zach", - ["2"] = "Steven" - }) - } - } -) - -local mock_device_no_data = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("base-lock.yml"), - data = {} - } -) -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init()end - -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "Device added data lock codes population", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device added without data should function", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, DoorLock.attributes.LockState:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, Alarm.attributes.AlarmCount:read(mock_device_no_data) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", nil) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", nil) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", nil) - end -) - -test.register_coroutine_test( - "Device init after added shouldn't change the datastores", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device init with new data should populate fields", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, DoorLock.attributes.LockState:read(mock_device_no_data) }) - test.socket.zigbee:__expect_send({ mock_device_no_data.id, Alarm.attributes.AlarmCount:read(mock_device_no_data) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "lockCodes", nil) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "__state_cache", {}) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - test.socket.device_lifecycle():__queue_receive(mock_device_no_data:generate_info_changed( - { - data = { - lockCodes = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) - } - } - )) - test.wait_for_events() - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device added data lock codes population, device response produces no events", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "lockCodes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - mock_datastore.__assert_device_store_contains(mock_device.id, "__state_cache", - { - main = { - lockCodes = { - lockCodes = {value = json.encode({ ["1"] = "Zach", ["2"] = "Steven" }) } - } - } - } - ) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.wait_for_events() - - -- run do_configure step after added and verify no refresh all codes - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.wait_for_events() - - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - PowerConfiguration.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, - 600, - 21600, - 1) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - DoorLock.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, - 0, - 3600, - 0) }) - test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - Alarm.ID) }) - test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, - 0, - 21600, - 0) }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - -- Validate migration reload skipped datastore - test.wait_for_events() - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationReloadSkipped", true) - -- Verify the timer doesn't fire as it wasn't created - test.mock_time.advance_time(4) - test.wait_for_events() - end -) - - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua new file mode 100644 index 0000000000..accfb345ac --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_legacy.lua @@ -0,0 +1,946 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local PowerConfiguration = clusters.PowerConfiguration +local DoorLock = clusters.DoorLock +local Alarm = clusters.Alarms +local capabilities = require "st.capabilities" + +local DoorLockState = DoorLock.attributes.LockState +local OperationEventCode = DoorLock.types.OperationEventCode +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local ProgrammingEventCode = DoorLock.types.ProgramEventCode + +local consts = require "lock_utils.constants" +local json = require "dkjson" + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("base-lock.yml") } +) +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +local expect_reload_all_codes_messages = function() + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, + true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 0) }) +end + +test.register_coroutine_test( + "Configure should configure all necessary attributes and begin reading codes", + function() + mock_device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, false, { persist = true }) + test.wait_for_events() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.wait_for_events() + + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, + 600, + 21600, + 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + DoorLock.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:configure_reporting(mock_device, + 0, + 3600, + 0) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + Alarm.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:configure_reporting(mock_device, + 0, + 21600, + 0) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + test.mock_time.advance_time(2) + expect_reload_all_codes_messages() + + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Refresh should read expected attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }}) + + test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.LockState:read(mock_device)}) + test.socket.zigbee:__expect_send({mock_device.id, Alarm.attributes.AlarmCount:read(mock_device)}) + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Lock status reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, + DoorLockState.LOCKED) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, + 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Lock operation event reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x02, + OperationEventCode.LOCK, + 0x0000, + "", + 0x0000, + "") } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Pin response reporting should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x02, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 set", + { data = { codeName = "Code 2" }, state_change = true })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["2"] = "Code 2"} ), { visibility = { displayed = false } })) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Sending the lock command should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "lock", component = "main", command = "lock", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, DoorLock.server.commands.LockDoor(mock_device) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Min lock code length report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 4) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false }})) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Max lock code length report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 4) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(4, { visibility = { displayed = false }})) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Max user code number report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 16) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false }})) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Reloading all codes of an unconfigured lock should generate correct attribute checks", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) + expect_reload_all_codes_messages() + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Requesting a user code should be handled", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = capabilities.lockCodes.ID, command = "requestCode", args = { 1 } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) } + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Deleting a user code should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ mock_device.id, DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", + { data = { codeName = "Code 1" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode( {["1"] = "Code 1"} ), { visibility = { displayed = false }}) + )) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "deleteCode", args = { 1 } } }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.SendPINOverTheAir:write(mock_device, + true) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 1) }) + test.wait_for_events() + + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + "")}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 deleted", + { data = { codeName = "Code 1"}, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) + )) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Setting a user code should result in the named code changed event firing", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + } + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 + } +) + +local function init_code_slot(slot_number, name, device) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { slot_number, "1234", name } } }) + test.socket.zigbee:__expect_send( + { + device.id, + DoorLock.server.commands.SetPINCode(device, + slot_number, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send( + { + device.id, + DoorLock.server.commands.GetPINCode(device, slot_number) + } + ) + test.wait_for_events() + test.socket.zigbee:__queue_receive( + { + device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + device, + slot_number, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(device:generate_test_message("main", + capabilities.lockCodes.codeChanged(slot_number .. " set", { data = { codeName = name }, state_change = true })) + ) +end + +test.register_coroutine_test( + "Setting a user code name should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "nameSlot", args = { 1, "foo" } } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Setting a user code name via setCode should be handled", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "", "foo"} } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Calling updateCodes should send properly spaced commands", + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "updateCodes", args = {{code1 = "1234", code2 = "2345", code3 = "3456", code4 = ""}}}}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 2, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "2345" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 3, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "3456" + ) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 4) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 1) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 2) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 3) + }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 4) + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Setting all user codes should result in a code set event for each", + function () + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "updateCodes", args = {{code1 = "1234", code2 = "2345", code3 = "3456", code4 = ""}}}}) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 1, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "1234")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 2, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "2345")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 2) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({mock_device.id, DoorLock.server.commands.SetPINCode(mock_device, 3, DoorLockUserStatus.OCCUPIED_ENABLED, DoorLockUserType.UNRESTRICTED, "3456")}) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 3) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.ClearPINCode(mock_device, 4) }) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 4) }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Master code programming event should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x00, + ProgrammingEventCode.MASTER_CODE_CHANGED, + 0, + "1234", + DoorLockUserType.MASTER_USER, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + } + }, + + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) + ) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "The lock reporting a single code has been set should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_ADDED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "Code 1"}, state_change = true })) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } })) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "The lock reporting a code has been deleted should be handled", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 1, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "Code 1"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "The lock reporting that all codes have been deleted should be handled", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + init_code_slot(2, "Code 2", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1", ["2"] = "Code 2"}), { visibility = { displayed = false } }))) + init_code_slot(3, "Code 3", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1", ["2"] = "Code 2", ["3"] = "Code 3"}), { visibility = { displayed = false } }))) + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + mock_device, + 0x0, + ProgrammingEventCode.PIN_CODE_DELETED, + 0xFF, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.AVAILABLE, + 0x0000, + "data" + ) + } + ) + + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "Code 1"}, state_change = true }) + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("2 deleted", { data = { codeName = "Code 2"}, state_change = true }) + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("3 deleted", { data = { codeName = "Code 3"}, state_change = true }) + ) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } }))) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "The lock reporting unlock via code should include the code info in the report", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.UNLOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Lock state attribute reports (after the first) should be delayed if they come before event notifications ", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.UNLOCKED)}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked() + ) + ) + test.mock_time.advance_time(2) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.UNLOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + test.mock_time.advance_time(2) + test.timer.__create_and_queue_test_time_advance_timer(2.5, "oneshot") + test.socket.zigbee:__queue_receive({mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.LOCKED)}) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + mock_device, + 0x00, -- 0 = keypad + OperationEventCode.LOCK, + 0x0001, + "1234", + 0x0000, + "" + ) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) + ) + ) + test.mock_time.advance_time(2.5) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked() + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Alarm code 0 should generate lock unknown event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Alarm.client.commands.Alarm.build_test_rx(mock_device, 0x00, DoorLock.ID) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Alarm code 1 should generate lock unknown event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Alarm.client.commands.Alarm.build_test_rx(mock_device, 0x01, DoorLock.ID) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Pin response for unoccupied slot with no existing code should generate unset event", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x05, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("5 unset", + { data = { codeName = "Code 5" }, state_change = true })) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot should use changed change type", + function() + init_code_slot(1, "Code 1", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 0x01, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 changed", { data = { codeName = "Code 1" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_v10.lua similarity index 96% rename from drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_v10.lua index 80d10d092e..4d4a370168 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_lock_v10.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -22,6 +11,10 @@ local PowerConfiguration = clusters.PowerConfiguration local DoorLock = clusters.DoorLock local Alarm = clusters.Alarms local capabilities = require "st.capabilities" +-- Note: This is not the proper way to test against previous versions. +-- Instead, testing should be run against different lua lib artifacts +local version = require "version" +version.api = 10 local DoorLockState = DoorLock.attributes.LockState local OperationEventCode = DoorLock.types.OperationEventCode @@ -86,7 +79,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -98,7 +94,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, DoorLock.attributes.LockState:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, Alarm.attributes.AlarmCount:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -115,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()) } + }, + { + min_api_version = 14 } ) @@ -132,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -156,6 +161,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 14 } ) @@ -186,6 +194,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["2"] = "Code 2"} ), { visibility = { displayed = false } })) } + }, + { + min_api_version = 14 } ) @@ -202,6 +213,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, DoorLock.server.commands.LockDoor(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -218,6 +232,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.minCodeLength(4, { visibility = { displayed = false }})) } + }, + { + min_api_version = 14 } ) @@ -234,6 +251,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodeLength(4, { visibility = { displayed = false }})) } + }, + { + min_api_version = 14 } ) @@ -251,6 +271,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false }})) } + }, + { + min_api_version = 14 } ) @@ -259,7 +282,10 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -275,6 +301,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) } } + }, + { + min_api_version = 14 } ) @@ -314,7 +343,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -361,7 +393,10 @@ test.register_coroutine_test( capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test" }, state_change = true }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false } }))) - end + end, + { + min_api_version = 14 + } ) local function init_code_slot(slot_number, name, device) @@ -416,7 +451,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -431,7 +469,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false } }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -502,7 +543,10 @@ test.register_coroutine_test( DoorLock.server.commands.GetPINCode(mock_device, 4) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -534,7 +578,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 4) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -566,6 +613,9 @@ test.register_message_test( capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) ) } + }, + { + min_api_version = 14 } ) @@ -602,6 +652,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "Code 1"}), { visibility = { displayed = false } })) } + }, + { + min_api_version = 14 } ) @@ -634,7 +687,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -688,7 +744,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false } }))) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -716,7 +775,10 @@ test.register_coroutine_test( capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -779,7 +841,10 @@ test.register_coroutine_test( capabilities.lock.lock.locked() ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua new file mode 100644 index 0000000000..e561a4867a --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_responses.lua @@ -0,0 +1,566 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Additional tests for lock_handlers/zigbee_responses.lua to improve coverage. +-- Covers: +-- • get_pin_code_response: sync codes from lock flow +-- • programming_event_notification: Yale/ASSA ABLOY user_id >= 256 shift +-- • operating_event_notification: keypad source with lockUsers capability +-- • operating_event_notification: schedule events with non-keypad source (auto method) +-- • alarm: alarm codes 0 and 1 +-- • lock_state: attribute handler with delay logic +-- • max_pin_code_length / min_pin_code_length: attribute handlers +-- • number_of_pin_users_supported: attribute handler with profile migration + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local DoorLock = clusters.DoorLock +local Alarms = clusters.Alarms +local constants = require "lock_utils.constants" +local table_utils = require "lock_utils.tables" + +local DoorLockUserStatus = DoorLock.types.DrlkUserStatus +local DoorLockUserType = DoorLock.types.DrlkUserType +local OperationEventCode = DoorLock.types.OperationEventCode +local OperationEventSource = DoorLock.types.DrlkOperationEventSource +local ProgrammingEventCode = DoorLock.types.ProgramEventCode +local LockState = DoorLock.attributes.LockState + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), +}) + +local mock_device_yale = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Yale", + model = "YRD256", + server_clusters = { DoorLock.ID }, + }, + }, +}) + +local mock_device_no_lock_codes = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("lock-without-codes.yml"), +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_yale) + test.mock_device.add_test_device(mock_device_no_lock_codes) +end + +test.set_test_init_function(test_init) + +-- Helper: build OperatingEventNotification +local function build_operating_event(device, event_code, event_source, user_id) + return { + device.id, + DoorLock.client.commands.OperatingEventNotification.build_test_rx( + device, + event_source, + event_code, + user_id or 0, + "1234", + 0x0000, + "data" + ), + } +end + +-- Helper: build ProgrammingEventNotification +local function build_programming_event(device, event_code, user_id) + return { + device.id, + DoorLock.client.commands.ProgrammingEventNotification.build_test_rx( + device, + 0x00, + event_code, + user_id, + "1234", + DoorLockUserType.UNRESTRICTED, + DoorLockUserStatus.OCCUPIED_ENABLED, + 0x0000, + "data" + ), + } +end + +-- ============================================================================ +-- get_pin_code_response: sync codes from lock +-- ============================================================================ + +test.register_coroutine_test( + "get_pin_code_response: syncs codes from lock and requests next code", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + -- Set up the sync state + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + -- Receive GetPINCodeResponse for user_id 1 + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, -- user_id + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" -- PIN code + ), + }) + + -- Should add user entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + -- Should add credential entry + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + -- Should request next code + test.socket.zigbee:__expect_send({ + mock_device.id, + DoorLock.server.commands.GetPINCode(mock_device, 2), + }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "get_pin_code_response: completes sync when max entries reached", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Set up the sync state at the last code index + -- get_max_entries defaults to 20 when attribute is missing, so use user_id 20 + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 20, {}) + + -- Receive GetPINCodeResponse for user_id 20 (at default max) + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 20, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ), + }) + + -- Should add entries + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 20, userName = "Guest 20", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 20, credentialIndex = 20, credentialType = "pin", credentialName = "Guest 20" } }, + { visibility = { displayed = false } } + )) + ) + -- Should NOT request next code (sync complete) + test.wait_for_events() + + -- Verify sync state is cleared (clear_busy_state sets BUSY to false, not nil) + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil) + assert(mock_device:get_field(constants.DRIVER_STATE.BUSY) == false) + end +) + +-- ============================================================================ +-- programming_event_notification: Yale user_id shift +-- ============================================================================ + +test.register_coroutine_test( + "programming_event_notification: Yale device shifts user_id >= 256", + function() + mock_device_yale:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Send a PIN_CODE_ADDED with user_id 256 (0x100), which should shift to 1 + test.socket.zigbee:__queue_receive( + build_programming_event(mock_device_yale, ProgrammingEventCode.PIN_CODE_ADDED, 256) + ) + + -- After shifting 256 >> 8 = 1, should add entries for user_id 1 + test.socket.capability:__expect_send( + mock_device_yale:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device_yale:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- operating_event_notification: keypad with lockUsers (user info lookup) +-- ============================================================================ + +test.register_coroutine_test( + "operating_event_notification: keypad unlock includes user info when user and credential exist", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- First, add a user entry (using valid userType "guest") + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "John Doe", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + table_utils.add_entry(mock_device, "users", { + userIndex = 1, + userName = "John Doe", + userType = "guest", + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "John's PIN" } }, + { visibility = { displayed = false } } + )) + ) + table_utils.add_entry(mock_device, "credentials", { + userIndex = 1, + credentialIndex = 1, + credentialType = "pin", + credentialName = "John's PIN", + }) + test.wait_for_events() + + -- Send unlock event from keypad with user_id 1 + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.UNLOCK, OperationEventSource.KEYPAD, 1) + ) + + -- Should emit unlocked with user info + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 1, + userName = "John Doe", + userType = "guest", + }, + }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: keypad unlock with unknown user uses default name", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + -- Send unlock event from keypad with user_id 99 (no matching entry) + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.UNLOCK, OperationEventSource.KEYPAD, 99) + ) + + -- Should emit unlocked with default user name + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 99, + userName = "Guest 99", + }, + }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- operating_event_notification: schedule events with "auto" method +-- ============================================================================ + +test.register_coroutine_test( + "operating_event_notification: SCHEDULE_LOCK with RF source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.SCHEDULE_LOCK, OperationEventSource.RF, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: SCHEDULE_UNLOCK with MANUAL source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.SCHEDULE_UNLOCK, OperationEventSource.MANUAL, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "operating_event_notification: AUTO_LOCK with RF source uses auto method", + function() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive( + build_operating_event(mock_device, OperationEventCode.AUTO_LOCK, OperationEventSource.RF, 0) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- alarm: alarm handler +-- ============================================================================ + +test.register_coroutine_test( + "alarm: alarm code 0 emits lock.unknown", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 0, DoorLock.ID), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "alarm: alarm code 1 emits lock.unknown", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 1, DoorLock.ID), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "alarm: unrecognized alarm code does not emit event", + function() + -- Alarm code 16 (low battery) is not in ALARM_REPORT map + test.socket.zigbee:__queue_receive({ + mock_device.id, + Alarms.client.commands.Alarm.build_test_rx(mock_device, 16, DoorLock.ID), + }) + + -- No event should be emitted + test.wait_for_events() + end +) + +-- ============================================================================ +-- lock_state: attribute handler +-- ============================================================================ + +test.register_coroutine_test( + "lock_state: LOCKED state emits locked event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.LOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "lock_state: UNLOCKED state emits unlocked event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.UNLOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "lock_state: NOT_FULLY_LOCKED state emits unknown event", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + LockState:build_test_attr_report(mock_device, LockState.NOT_FULLY_LOCKED), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unknown()) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- max_pin_code_length / min_pin_code_length: attribute handlers +-- ============================================================================ + +test.register_coroutine_test( + "max_pin_code_length: emits maxPinCodeLen event", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.MaxPINCodeLength:build_test_attr_report(mock_device, 8), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.maxPinCodeLen(8, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "min_pin_code_length: emits minPinCodeLen event", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.MinPINCodeLength:build_test_attr_report(mock_device, 4), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.minPinCodeLen(4, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +-- ============================================================================ +-- number_of_pin_users_supported: attribute handler +-- ============================================================================ + +test.register_coroutine_test( + "number_of_pin_users_supported: emits pinUsersSupported and totalUsersSupported", + function() + -- Set SLGA_MIGRATED so we route to the new handlers + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, 20), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(20, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(20, { visibility = { displayed = false } }) + ) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "number_of_pin_users_supported: triggers profile migration when device has no lockCodes", + function() + test.socket.zigbee:__queue_receive({ + mock_device_no_lock_codes.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device_no_lock_codes, 10), + }) + + -- Should trigger profile update to base-lock + mock_device_no_lock_codes:expect_metadata_update({ profile = "base-lock" }) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "number_of_pin_users_supported: zero value does not trigger profile migration", + function() + test.socket.zigbee:__queue_receive({ + mock_device_no_lock_codes.id, + DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device_no_lock_codes, 0), + }) + + -- No profile update should occur (value is 0) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua index bac1554790..882d8f5efe 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_samsungsds.lua @@ -30,6 +30,7 @@ local SAMSUNG_SDS_MFR_CODE = 0x0003 local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("lock-without-codes.yml"), + provisioning_state = "TYPED", zigbee_endpoints = { [1] = { id = 1, @@ -49,6 +50,20 @@ end test.set_test_init_function(test_init) +local constants = require "lock_utils.constants" +test.register_coroutine_test( + "Device init function handler", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init"}) + test.socket.capability:__set_channel_ordering("relaxed") + test.wait_for_events() + assert(mock_device:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) == true, "Device init did not set migrated field to true") + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test( "Configure should configure all necessary attributes", function() @@ -74,7 +89,10 @@ test.register_coroutine_test( ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -91,6 +109,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked()) } + }, + { + min_api_version = 17 } ) @@ -108,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) } + }, + { + min_api_version = 17 } ) @@ -120,6 +144,9 @@ test.register_message_test( message = { mock_device.id, DoorLock.attributes.LockState:build_test_attr_report(mock_device, DoorLockState.NOT_FULLY_LOCKED) } } + }, + { + min_api_version = 17 } ) @@ -146,6 +173,9 @@ test.register_message_test( capabilities.lock.lock.locked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -172,6 +202,9 @@ test.register_message_test( capabilities.lock.lock.unlocked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -198,6 +231,9 @@ test.register_message_test( capabilities.lock.lock.locked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -224,6 +260,9 @@ test.register_message_test( capabilities.lock.lock.locked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -250,6 +289,9 @@ test.register_message_test( capabilities.lock.lock.unlocked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -276,6 +318,9 @@ test.register_message_test( capabilities.lock.lock.locked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -302,6 +347,9 @@ test.register_message_test( capabilities.lock.lock.locked({data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -328,6 +376,9 @@ test.register_message_test( capabilities.lock.lock.unlocked({ data = { method = "keypad"} }) ) } + }, + { + min_api_version = 17 } ) @@ -352,6 +403,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -376,6 +430,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -400,6 +457,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -424,6 +484,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -448,6 +511,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -472,6 +538,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -496,6 +565,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -520,6 +592,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "command" } })) } + }, + { + min_api_version = 17 } ) @@ -544,6 +619,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -568,6 +646,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -592,6 +673,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -616,6 +700,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -640,6 +727,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -664,6 +754,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -688,6 +781,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -712,6 +808,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "manual" } })) } + }, + { + min_api_version = 17 } ) @@ -736,6 +835,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -760,6 +862,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -784,6 +889,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -808,6 +916,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -832,6 +943,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -856,6 +970,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -880,6 +997,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -904,6 +1024,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "rfid" } })) } + }, + { + min_api_version = 17 } ) @@ -928,6 +1051,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -952,6 +1078,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -976,6 +1105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1000,6 +1132,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1024,6 +1159,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1048,6 +1186,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -1072,6 +1213,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1096,6 +1240,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "fingerprint" } })) } + }, + { + min_api_version = 17 } ) @@ -1120,6 +1267,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1144,6 +1294,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1168,6 +1321,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1192,6 +1348,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1216,6 +1375,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1240,6 +1402,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "auto" } })) } + }, + { + min_api_version = 17 } ) @@ -1264,6 +1429,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1288,6 +1456,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({ data = { method = "bluetooth" } })) } + }, + { + min_api_version = 17 } ) @@ -1308,7 +1479,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -1327,6 +1501,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) }, + }, + { + min_api_version = 19 } ) @@ -1341,6 +1518,9 @@ test.register_message_test( zigbee_test_utils.build_custom_command_id(mock_device, DoorLock.ID, SAMSUNG_SDS_MFR_SPECIFIC_COMMAND, SAMSUNG_SDS_MFR_CODE, "") } } + }, + { + min_api_version = 19 } ) @@ -1358,7 +1538,10 @@ test.register_coroutine_test( zigbee_test_utils.build_tx_custom_command_id(mock_device, DoorLock.ID, SAMSUNG_SDS_MFR_SPECIFIC_COMMAND, SAMSUNG_SDS_MFR_CODE, "1235") }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1371,7 +1554,10 @@ test.register_coroutine_test( } ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -1388,7 +1574,10 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua deleted file mode 100644 index ee1745e3b7..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-bad-battery-reporter.lua +++ /dev/null @@ -1,49 +0,0 @@ --- Copyright 2022 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. - --- Mock out globals -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local PowerConfiguration = clusters.PowerConfiguration - -local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), zigbee_endpoints ={ [1] = {id = 1, manufacturer ="Yale", model ="YRD220/240 TSDB", server_clusters = {}} } }) - -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - test.mock_device.add_test_device(mock_device)end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Battery percentage report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, - 55) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) - } - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua deleted file mode 100644 index 2255c063a3..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale-fingerprint-lock.lua +++ /dev/null @@ -1,49 +0,0 @@ --- Copyright 2022 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. - --- Mock out globals -local test = require "integration_test" -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" -local DoorLock = clusters.DoorLock - -local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), zigbee_endpoints ={ [1] = {id = 1, manufacturer ="ASSA ABLOY iRevo", model ="iZBModule01", server_clusters = {}} } }) - -zigbee_test_utils.prepare_zigbee_env_info() -local function test_init() - test.mock_device.add_test_device(mock_device)end - -test.set_test_init_function(test_init) - -test.register_message_test( - "Max user code number report should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, - 16) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(30)) - } - } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua new file mode 100644 index 0000000000..560e0b3768 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_fingerprint-lock.lua @@ -0,0 +1,78 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local DoorLock = clusters.DoorLock +local Alarm = clusters.Alarms + +local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("base-lock.yml"), zigbee_endpoints ={ [1] = {id = 1, manufacturer ="ASSA ABLOY iRevo", model ="iZBModule01", server_clusters = {}} } }) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +local function test_init_new_capabilities() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.LockState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Alarm.attributes.AlarmCount:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MaxPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.MinPINCodeLength:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:read(mock_device) }) +end + +test.register_message_test( + "Max user code number report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 16) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(30)) + } + }, + { + test_init = test_init, + min_api_version = 14 + } +) + +test.register_message_test( + "Max user code number report should be handled for migrated locks", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, DoorLock.attributes.NumberOfPINUsersSupported:build_test_attr_report(mock_device, + 16) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(30)) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(30)) + } + }, + { test_init = test_init_new_capabilities } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua similarity index 75% rename from drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua rename to drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua index 34b6881028..82e487439b 100644 --- a/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale.lua +++ b/drivers/SmartThings/zigbee-lock/src/test/test_zigbee_yale_legacy.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -56,7 +45,10 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -95,7 +87,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -142,7 +137,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "test"}), { visibility = { displayed = false }}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -189,7 +187,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["0"] = "test"}), { visibility = { displayed = false }}))) - end + end, + { + min_api_version = 14 + } ) @@ -215,6 +216,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("0 unset", { data = { codeName = "Code 0" }, state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -270,7 +274,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false }}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -312,7 +319,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 failed", { state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -327,7 +337,113 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["1"] = "foo"}), { visibility = { displayed = false }}))) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Setting a user code for a slot that is empty should indicate failure and unset", + function() + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "setCode", args = { 1, "1234", "test" } } }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + DoorLock.server.commands.SetPINCode(mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zigbee:__expect_send({ mock_device.id, DoorLock.server.commands.GetPINCode(mock_device, 1) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 failed", { state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 is not set", { state_change = true }))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot without pending operation should use changed change type", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_ENABLED, + DoorLockUserType.UNRESTRICTED, + "1234" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 changed", { data = { codeName = "initialName" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Pin response for already-set slot that is now empty should delete the code", + function() + init_code_slot(1, "initialName", mock_device) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({["1"] = "initialName"}), { visibility = { displayed = false }}))) + test.wait_for_events() + + test.socket.zigbee:__queue_receive( + { + mock_device.id, + DoorLock.client.commands.GetPINCodeResponse.build_test_rx( + mock_device, + 1, + DoorLockUserStatus.OCCUPIED_DISABLED, + DoorLockUserType.UNRESTRICTED, + "" + ) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.codeChanged("1 deleted", { data = { codeName = "initialName" }, state_change = true }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.lockCodes.lockCodes(json.encode({}), { visibility = { displayed = false }}))) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua new file mode 100644 index 0000000000..48a6521059 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local yale_fingerprint_lock_models = function(opts, driver, device) + local consts = require "lock_utils.constants" + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false + if not slga_migrated then return false end + local FINGERPRINTS = require("yale-fingerprint-lock.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("yale-fingerprint-lock") + end + end + return false +end + +return yale_fingerprint_lock_models diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua new file mode 100644 index 0000000000..b3db27d719 --- /dev/null +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local YALE_FINGERPRINT_LOCK = { + { mfr = "ASSA ABLOY iRevo", model = "iZBModule01" }, + { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, + { mfr = "ASSA ABLOY iRevo", model = "0700000001" }, + { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } +} + +return YALE_FINGERPRINT_LOCK diff --git a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua index 9d0a0b4148..9965ef181e 100644 --- a/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua +++ b/drivers/SmartThings/zigbee-lock/src/yale-fingerprint-lock/init.lua @@ -1,42 +1,20 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local LockCluster = clusters.DoorLock -local LockCodes = capabilities.lockCodes +local LockCredentials = capabilities.lockCredentials +local LockUsers = capabilities.lockUsers local YALE_FINGERPRINT_MAX_CODES = 0x1E -local YALE_FINGERPRINT_LOCK = { - { mfr = "ASSA ABLOY iRevo", model = "iZBModule01" }, - { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, - { mfr = "ASSA ABLOY iRevo", model = "0700000001" }, - { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } -} -local yale_fingerprint_lock_models = function(opts, driver, device) - for _, fingerprint in ipairs(YALE_FINGERPRINT_LOCK) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local handle_max_codes = function(driver, device, value) - device:emit_event(LockCodes.maxCodes(YALE_FINGERPRINT_MAX_CODES), { visibility = { displayed = false } }) + device:emit_event(LockCredentials.pinUsersSupported(YALE_FINGERPRINT_MAX_CODES)) + device:emit_event(LockUsers.totalUsersSupported(YALE_FINGERPRINT_MAX_CODES)) end local yale_fingerprint_lock_driver = { @@ -48,7 +26,7 @@ local yale_fingerprint_lock_driver = { } } }, - can_handle = yale_fingerprint_lock_models + can_handle = require("yale-fingerprint-lock.can_handle") } return yale_fingerprint_lock_driver diff --git a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua b/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua deleted file mode 100644 index 59fdbf228b..0000000000 --- a/drivers/SmartThings/zigbee-lock/src/yale/yale-bad-battery-reporter/init.lua +++ /dev/null @@ -1,52 +0,0 @@ --- Copyright 2022 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. - -local clusters = require "st.zigbee.zcl.clusters" -local capabilities = require "st.capabilities" - -local BAD_YALE_LOCK_FINGERPRINTS = { - { mfr = "Yale", model = "YRD220/240 TSDB" }, - { mfr = "Yale", model = "YRL220 TS LL" }, - { mfr = "Yale", model = "YRD210 PB DB" }, - { mfr = "Yale", model = "YRL210 PB LL" }, - { mfr = "ASSA ABLOY iRevo", model = "c700000202" }, - { mfr = "ASSA ABLOY iRevo", model = "06ffff2027" } -} - -local is_bad_yale_lock_models = function(opts, driver, device) - for _, fingerprint in ipairs(BAD_YALE_LOCK_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - -local battery_report_handler = function(driver, device, value) - device:emit_event(capabilities.battery.battery(value.value)) -end - -local bad_yale_driver = { - NAME = "YALE BAD Lock Driver", - zigbee_handlers = { - attr = { - [clusters.PowerConfiguration.ID] = { - [clusters.PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_report_handler - } - } - }, - can_handle = is_bad_yale_lock_models -} - -return bad_yale_driver diff --git a/drivers/SmartThings/zigbee-motion-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-motion-sensor/fingerprints.yml index 28aaea8d33..89d3df1586 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-motion-sensor/fingerprints.yml @@ -24,6 +24,11 @@ zigbeeManufacturer: manufacturer: eWeLink model: MSO1 deviceProfileName: motion-battery + - id: eWeLink/SNZB-03P + deviceLabel: Zigbee Motion Sensor + manufacturer: eWeLink + model: SNZB-03P + deviceProfileName: motion - id: "ORVIBO/895a2d80097f4ae2b2d40500d5e0" deviceLabel: Orvibo Motion Sensor manufacturer: ORVIBO @@ -204,6 +209,11 @@ zigbeeManufacturer: manufacturer: sengled model: E1M-G7H deviceProfileName: motion-battery + - id: "MultIR/MIR-IR100" + deviceLabel: MultiIR Motion Detector MIR-IR100 + manufacturer: MultIR + model: MIR-IR100 + deviceProfileName: motion-battery-illuminance-sensitivity-frequency-no-fw-update zigbeeGeneric: - id: kickstarter/motion/1 deviceLabel: SmartThings Motion Sensor diff --git a/drivers/SmartThings/zigbee-motion-sensor/profiles/motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml b/drivers/SmartThings/zigbee-motion-sensor/profiles/motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml new file mode 100644 index 0000000000..5fc7d7ea8b --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/profiles/motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml @@ -0,0 +1,26 @@ +name: motion-battery-illuminance-sensitivity-frequency-no-fw-update +components: + - id: main + capabilities: + - id: motionSensor + version: 1 + - id: illuminanceMeasurement + version: 1 + - id: stse.sensitivityAdjustment + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: MotionSensor +preferences: + - title: "检测频率/秒(detection frequency/sec)" + name: detectionfrequency + description: "传感器检测频率(Sensor detects frequency unit: seconds)" + required: false + preferenceType: integer + definition: + minimum: 10 + maximum: 1800 + default: 60 diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..139d3aa054 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-IR100" } +} diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/init.lua new file mode 100644 index 0000000000..c3c47753e1 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/MultiIR/init.lua @@ -0,0 +1,117 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" +local zcl_messages = require "st.zigbee.zcl" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" + +local sensitivityAdjustment = capabilities["stse.sensitivityAdjustment"] +local sensitivityAdjustmentCommandName = "setSensitivityAdjustment" +local IASZone = zcl_clusters.IASZone +local IASZone_PRIVATE_COMMAND_ID = 0xF4 + +local PREF_SENSITIVITY_VALUE_HIGH = 3 +local PREF_SENSITIVITY_VALUE_MEDIUM = 2 +local PREF_SENSITIVITY_VALUE_LOW = 1 + +local function send_iaszone_private_cmd(device, priv_cmd, data) + local frame_ctrl = FrameCtrl(0x00) + frame_ctrl:set_cluster_specific() + + local zclh = zcl_messages.ZclHeader({ + frame_ctrl = frame_ctrl, + cmd = data_types.ZCLCommandId(priv_cmd) + }) + + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = data_types.Uint16(data) + }) + + local addr_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(IASZone.ID), + zb_const.HA_PROFILE_ID, + IASZone.ID + ) + + local zigbee_msg = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + + device:send(zigbee_msg) +end + +local function iaszone_attr_sen_handler(driver, device, value, zb_rx) + if value.value == PREF_SENSITIVITY_VALUE_HIGH then + device:emit_event(sensitivityAdjustment.sensitivityAdjustment.High()) + elseif value.value == PREF_SENSITIVITY_VALUE_MEDIUM then + device:emit_event(sensitivityAdjustment.sensitivityAdjustment.Medium()) + elseif value.value == PREF_SENSITIVITY_VALUE_LOW then + device:emit_event(sensitivityAdjustment.sensitivityAdjustment.Low()) + end +end + +local function send_sensitivity_adjustment_value(device, value) + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:write(device, value)) +end + +local function sensitivity_adjustment_capability_handler(driver, device, command) + local sensitivity = command.args.sensitivity + if sensitivity == 'High' then + send_sensitivity_adjustment_value(device, PREF_SENSITIVITY_VALUE_HIGH) + elseif sensitivity == 'Medium' then + send_sensitivity_adjustment_value(device, PREF_SENSITIVITY_VALUE_MEDIUM) + elseif sensitivity == 'Low' then + send_sensitivity_adjustment_value(device, PREF_SENSITIVITY_VALUE_LOW) + end + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:read(device)) +end + +local function added_handler(self, device) + device:emit_event(capabilities.motionSensor.motion.inactive()) + device:emit_event(sensitivityAdjustment.sensitivityAdjustment.High()) + device:emit_event(capabilities.battery.battery(100)) + device:send(IASZone.attributes.CurrentZoneSensitivityLevel:read(device)) +end + +local function info_changed(driver, device, event, args) + for name, value in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "detectionfrequency") then + local detectionfrequency = tonumber(device.preferences.detectionfrequency) + send_iaszone_private_cmd(device, IASZone_PRIVATE_COMMAND_ID, detectionfrequency) + end + end + end +end + +local MultiIR_motion_handler = { + NAME = "MultiIR motion handler", + lifecycle_handlers = { + added = added_handler, + infoChanged = info_changed + }, + capability_handlers = { + [sensitivityAdjustment.ID] = { + [sensitivityAdjustmentCommandName] = sensitivity_adjustment_capability_handler, + } + }, + zigbee_handlers = { + attr = { + [IASZone.ID] = { + [IASZone.attributes.CurrentZoneSensitivityLevel.ID] = iaszone_attr_sen_handler + } + } + }, + can_handle = require("MultiIR.can_handle") +} + +return MultiIR_motion_handler diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/init.lua b/drivers/SmartThings/zigbee-motion-sensor/src/init.lua index 11e9d94a85..9c3f33ad14 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/init.lua @@ -118,12 +118,14 @@ local zigbee_motion_driver = { lazy_load_if_possible("smartsense"), lazy_load_if_possible("thirdreality"), lazy_load_if_possible("sengled"), + lazy_load_if_possible("MultiIR"), }, additional_zcl_profiles = { [0xFC01] = true }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_motion_driver, zigbee_motion_driver.supported_capabilities, {native_capability_attrs_enabled = true}) diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_all_capabilities_zigbee_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_all_capabilities_zigbee_motion.lua index 948d433c9f..07cb3a9d99 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_all_capabilities_zigbee_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_all_capabilities_zigbee_motion.lua @@ -46,6 +46,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -70,6 +73,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -94,6 +100,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -118,6 +127,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -142,6 +154,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -165,6 +180,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -181,6 +199,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 })) } + }, + { + min_api_version = 14 } ) @@ -197,6 +218,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -303,7 +327,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -388,7 +415,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -407,7 +435,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) mock_device:expect_native_attr_handler_registration("temperatureMeasurement", "temperature") test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -424,7 +455,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 79 }))) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_high_precision.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_high_precision.lua index e7bfcbf642..3b87239092 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_high_precision.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_high_precision.lua @@ -71,7 +71,10 @@ test.register_coroutine_test( sensitivityAdjustment.sensitivityAdjustment.Medium())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -91,7 +94,10 @@ test.register_coroutine_test( , data_types.Uint8, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -112,7 +118,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -134,7 +143,10 @@ test.register_coroutine_test( test.mock_time.advance_time(detect_duration) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 14 + } ) local function build_write_attr_res(cluster, status) @@ -174,7 +186,10 @@ test.register_coroutine_test( local value = mock_device:get_field(PREF_CHANGED_VALUE) or 0 test.socket.capability:__expect_send(mock_device:generate_test_message("main", detectionFrequency.detectionFrequency(value, {visibility = {displayed = false}}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -188,7 +203,10 @@ test.register_coroutine_test( mock_device:set_field(PREF_CHANGED_VALUE, PREF_SENSITIVITY_VALUE_HIGH) test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.High())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -202,7 +220,10 @@ test.register_coroutine_test( mock_device:set_field(PREF_CHANGED_VALUE, PREF_SENSITIVITY_VALUE_MEDIUM) test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Medium())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -216,7 +237,10 @@ test.register_coroutine_test( mock_device:set_field(PREF_CHANGED_VALUE, PREF_SENSITIVITY_VALUE_LOW) test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Low())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -251,7 +275,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, SENSITIVITY_ATTRIBUTE_ID, MFG_CODE , data_types.Uint8, PREF_SENSITIVITY_VALUE_LOW) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_motion_illuminance.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_motion_illuminance.lua index 2580af0ceb..979c64d45b 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_motion_illuminance.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aqara_motion_illuminance.lua @@ -10,6 +10,10 @@ local data_types = require "st.zigbee.data_types" local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" +local write_attribute_response = require "st.zigbee.zcl.global_commands.write_attribute_response" +local zcl_messages = require "st.zigbee.zcl" test.add_package_capability("sensitivityAdjustment.yaml") test.add_package_capability("detectionFrequency.yaml") @@ -46,6 +50,29 @@ local function test_init() test.set_test_init_function(test_init) +local function build_write_attr_res(cluster, status) + local addr_header = messages.AddressHeader( + mock_device:get_short_address(), + mock_device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local write_attribute_body = write_attribute_response.WriteAttributeResponse(status, {}) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(write_attribute_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = write_attribute_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + test.register_coroutine_test( "Handle added lifecycle", function() @@ -59,7 +86,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", detectionFrequency.detectionFrequency(PREF_FREQUENCY_VALUE_DEFAULT, {visibility = {displayed = false}}))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -79,7 +109,10 @@ test.register_coroutine_test( , data_types.Uint8, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -104,7 +137,10 @@ test.register_coroutine_test( test.mock_time.advance_time(detect_duration) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -120,7 +156,71 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, FREQUENCY_ATTRIBUTE_ID, MFG_CODE , data_types.Uint8, PREF_FREQUENCY_VALUE_DEFAULT) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Motion detected twice cancels existing timer and creates a new one", + function() + local detect_duration = PREF_FREQUENCY_VALUE_DEFAULT + -- Pre-register two timers: first will be cancelled, second will fire + test.timer.__create_and_queue_test_time_advance_timer(detect_duration, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(detect_duration, "oneshot") + local attr_report_data = { + { MOTION_ILLUMINANCE_ATTRIBUTE_ID, data_types.Int32.ID, 0x0001006E } -- 65646 + } + -- First motion event + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(110)) + ) + test.wait_for_events() + -- Second motion event before first timer fires - cancels first timer + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(110)) + ) + test.wait_for_events() + -- Only the second timer fires + test.mock_time.advance_time(detect_duration) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.motionSensor.motion.inactive())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "WriteAttributeResponse with PREF_FREQUENCY_KEY updates detection frequency", + function() + mock_device:set_field(PREF_CHANGED_KEY, PREF_FREQUENCY_KEY) + mock_device:set_field(PREF_CHANGED_VALUE, PREF_FREQUENCY_VALUE_DEFAULT) + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_write_attr_res(PRIVATE_CLUSTER_ID, 0x00) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + detectionFrequency.detectionFrequency(PREF_FREQUENCY_VALUE_DEFAULT, {visibility = {displayed = false}}))) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aurora_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aurora_motion.lua index 1a5a292fc3..f209f4f18f 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aurora_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_aurora_motion.lua @@ -54,6 +54,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -78,6 +81,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -102,6 +108,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -126,6 +135,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -162,7 +174,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -204,7 +219,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_battery_voltage_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_battery_voltage_motion.lua index 737a594406..99c2da077d 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_battery_voltage_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_battery_voltage_motion.lua @@ -50,7 +50,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } + ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_centralite_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_centralite_motion.lua index 1e35976fac..94c6779b44 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_centralite_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_centralite_motion.lua @@ -69,7 +69,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -92,7 +95,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device_old_firmware:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_compacta_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_compacta_motion.lua index 0660661d80..03afa1c6e9 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_compacta_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_compacta_motion.lua @@ -75,7 +75,10 @@ test.register_coroutine_test( TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 300, 100):to_endpoint(0x03) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -96,7 +99,10 @@ test.register_coroutine_test( mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor.lua index 89d014dc90..42a30de572 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor.lua @@ -69,42 +69,12 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) } + }, + { + min_api_version = 14 } ) --- test.register_coroutine_test( --- "Health check should check all relevant attributes", --- function() --- test.wait_for_events() --- test.mock_time.advance_time(50000) --- test.socket.zigbee:__set_channel_ordering("relaxed") --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- IASZone.attributes.ZoneStatus:read(mock_device) --- } --- ) --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT) --- } --- ) --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT) --- } --- ) --- end, --- { --- test_init = function() --- test.mock_device.add_test_device(mock_device) --- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") --- end --- } --- ) - test.register_coroutine_test( "Refresh should read all necessary attributes", function() @@ -112,7 +82,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT)}) test.socket.zigbee:__expect_send({ mock_device.id, OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -130,6 +103,7 @@ test.register_coroutine_test( ) test.wait_for_events() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__expect_send({ @@ -227,7 +201,59 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + -- Advance time to trigger the do_refresh call scheduled in do_configure + test.wait_for_events() + test.mock_time.advance_time(5) + test.socket.zigbee:__expect_send({ + mock_device.id, + OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT) + }) + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Occupancy attribute handler emits motion active", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OccupancySensing.attributes.Occupancy:build_test_attr_report(mock_device, 0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Occupancy attribute handler emits motion inactive", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OccupancySensing.attributes.Occupancy:build_test_attr_report(mock_device, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -256,7 +282,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OccupancySensing.attributes.PIROccupiedToUnoccupiedDelay:write(mock_device, 200):to_endpoint(OCCUPANCY_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor2_pet.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor2_pet.lua index 6869412d49..b84bfc6b6e 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor2_pet.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor2_pet.lua @@ -77,7 +77,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -93,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) } + }, + { + min_api_version = 14 } ) @@ -117,6 +123,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -136,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 14 } ) @@ -187,7 +199,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT)}) test.socket.zigbee:__expect_send({mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT)}) test.socket.zigbee:__expect_send({mock_device.id, IlluminanceMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(ILLUMINANCE_ENDPOINT)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -342,7 +357,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -382,7 +400,10 @@ test.register_coroutine_test( temperatureSensitivity ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor_pro.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor_pro.lua index 9556a6e58e..f3e39499e0 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor_pro.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_frient_motion_sensor_pro.lua @@ -80,7 +80,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -96,6 +99,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) } + }, + { + min_api_version = 14 } ) @@ -113,6 +119,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -130,6 +139,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -154,6 +166,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -173,54 +188,12 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 14 } ) --- test.register_coroutine_test( --- "Health check should check all relevant attributes", --- function() --- test.wait_for_events() --- test.mock_time.advance_time(50000) --- test.socket.zigbee:__set_channel_ordering("relaxed") --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- IASZone.attributes.ZoneStatus:read(mock_device):to_endpoint(TAMPER_ENDPOINT) --- } --- ) --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- IlluminanceMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(ILLUMINANCE_ENDPOINT) --- } --- ) --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT) --- } --- ) --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- PowerConfiguration.attributes.BatteryVoltage:read(mock_device):to_endpoint(POWER_CONFIGURATION_ENDPOINT) --- } --- ) --- test.socket.zigbee:__expect_send( --- { --- mock_device.id, --- TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) --- } --- ) --- end, --- { --- test_init = function() --- test.mock_device.add_test_device(mock_device) --- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") --- end --- } --- ) - test.register_coroutine_test( "Refresh should read all necessary attributes", function() @@ -231,7 +204,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, OccupancySensing.attributes.Occupancy:read(mock_device):to_endpoint(OCCUPANCY_ENDPOINT)}) test.socket.zigbee:__expect_send({mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT)}) test.socket.zigbee:__expect_send({mock_device.id, IlluminanceMeasurement.attributes.MeasuredValue:read(mock_device):to_endpoint(ILLUMINANCE_ENDPOINT)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -389,7 +365,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -429,7 +408,50 @@ test.register_coroutine_test( temperatureSensitivity ):to_endpoint(TEMPERATURE_MEASUREMENT_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "IASZone attribute status handler: tamper detected", + { + { + channel = "zigbee", + direction = "receive", + -- ZoneStatus | Bit2: Tamper set to 1 + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "IASZone attribute status handler: tamper clear", + { + { + channel = "zigbee", + direction = "receive", + -- ZoneStatus | Bit2: Tamper set to 0 + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_gator_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_gator_motion.lua index 7f47416e93..343d4921c1 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_gator_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_gator_motion.lua @@ -44,7 +44,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) test.mock_time.advance_time(120) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -60,7 +63,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.presenceSensor.presence.present())) test.mock_time.advance_time(60) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.presenceSensor.presence.not_present())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -73,7 +79,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.open())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -86,7 +95,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -99,7 +111,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -112,7 +127,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -125,7 +143,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(10))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -140,7 +161,29 @@ test.register_coroutine_test( mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) test.wait_for_events() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "ZoneStatusChangeNotification with alarm1 triggers motion active and inactive", + function() + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) + } + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + test.mock_time.advance_time(120) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_ikea_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_ikea_motion.lua index 0173ac431d..bd1f959bc7 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_ikea_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_ikea_motion.lua @@ -58,6 +58,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -97,7 +100,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -117,13 +123,16 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(180) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "ZDO Message handler and adding hub to group", function() - local binding_table = mgmt_bind_response.BindingTableListRecord("j�Àþÿ^ÏÐ", 0x01, 0x0006, 0x01, 0xB9F2) + local binding_table = mgmt_bind_response.BindingTableListRecord("\x6A\x9D\xC0\xFE\xFF\x5E\xCF\xD0", 0x01, 0x0006, 0x01, 0xB9F2) local response = mgmt_bind_response.MgmtBindResponse({ status = 0x00, total_binding_table_entry_count = 0x01, @@ -138,13 +147,16 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_add_hub_to_group(0xB9F2) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Request all binding table entries and fall back to group 0x0000", function() - local binding_table_long = mgmt_bind_response.BindingTableListRecord("j�Àþÿ^ÏÐ", 0x01, 0x0006, 0x03, "DEADBEEF", 0x01) + local binding_table_long = mgmt_bind_response.BindingTableListRecord("\x6A\x9D\xC0\xFE\xFF\x5E\xCF\xD0", 0x01, 0x0006, 0x03, "DEADBEEF", 0x01) local response = mgmt_bind_response.MgmtBindResponse({ status = 0x00, total_binding_table_entry_count = 0x02, @@ -197,7 +209,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -220,7 +235,38 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, Groups.commands.AddGroup(mock_device, 0x0000) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Second OnWithTimedOff cancels existing timer and resets motion", + function() + local frm_ctrl = FrameCtrl(0x01) + -- Pre-register two timers: first will be cancelled, second will fire + test.timer.__create_and_queue_test_time_advance_timer(0x0708/10, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(0x0708/10, "oneshot") + -- First motion event + local first_cmd = OnOff.server.commands.OnWithTimedOff.build_test_rx(mock_device, 0x00, 0x0708, 0x0000) + first_cmd.body.zcl_header.frame_ctrl = frm_ctrl + test.socket.zigbee:__queue_receive({mock_device.id, first_cmd}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + test.wait_for_events() + -- Second motion event before first timer fires - cancels first timer + local second_cmd = OnOff.server.commands.OnWithTimedOff.build_test_rx(mock_device, 0x00, 0x0708, 0x0000) + second_cmd.body.zcl_header.frame_ctrl = frm_ctrl + test.socket.zigbee:__queue_receive({mock_device.id, second_cmd}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) + test.wait_for_events() + -- Only the second timer fires + test.mock_time.advance_time(180) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_multiir_motion_pir.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_multiir_motion_pir.lua new file mode 100644 index 0000000000..357190c730 --- /dev/null +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_multiir_motion_pir.lua @@ -0,0 +1,224 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" + +--If this line is removed, an error will occur. +test.add_package_capability("sensitivityAdjustment.yaml") + +local sensitivityAdjustment = capabilities["stse.sensitivityAdjustment"] +local IASZone = clusters.IASZone +local PowerConfiguration = clusters.PowerConfiguration + +local PREF_SENSITIVITY_VALUE_HIGH = 3 +local PREF_SENSITIVITY_VALUE_MEDIUM = 2 +local PREF_SENSITIVITY_VALUE_LOW = 1 +local IASZone_PRIVATE_COMMAND_ID = 0xF4 + +-- Needed for building iaszone_private_cmd msg +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" + +local zcl_messages = require "st.zigbee.zcl" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("motion-battery-illuminance-sensitivity-frequency-no-fw-update.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "MultIR", + model = "MIR-IR100", + server_clusters = { PowerConfiguration.ID ,IASZone.ID} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device)end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.motionSensor.motion.inactive())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + sensitivityAdjustment.sensitivityAdjustment.High())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.battery.battery(100))) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:read(mock_device)}) + end, + { + min_api_version = 19 + } +) + +local function build_iaszone_private_cmd(device, priv_cmd, data) + local frame_ctrl = FrameCtrl(0x00) + frame_ctrl:set_cluster_specific() + + local zclh = zcl_messages.ZclHeader({ + frame_ctrl = frame_ctrl, + cmd = data_types.ZCLCommandId(priv_cmd) + }) + + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = data_types.Uint16(data) + }) + + local addr_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(IASZone.ID), + zb_const.HA_PROFILE_ID, + IASZone.ID + ) + + local msg = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + + return msg +end + +test.register_coroutine_test( + "Handle detectionFrequency preference in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({preferences = {detectionfrequency = 63}})) + test.socket.zigbee:__expect_send( + { + mock_device.id, + build_iaszone_private_cmd(mock_device,IASZone_PRIVATE_COMMAND_ID, 63) + } + ) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported CurrentZoneSensitivityLevel 1 should be Low", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.CurrentZoneSensitivityLevel:build_test_attr_report(mock_device, + 1) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Low()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported CurrentZoneSensitivityLevel 2 should be Medium", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.CurrentZoneSensitivityLevel:build_test_attr_report(mock_device, + 2) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Medium()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported CurrentZoneSensitivityLevel 3 should be High", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.CurrentZoneSensitivityLevel:build_test_attr_report(mock_device, + 3) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.High()) + } + }, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability sensitivityAdjustment High should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.sensitivityAdjustment", component = "main", command = "setSensitivityAdjustment", args = {"High"} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:write(mock_device, PREF_SENSITIVITY_VALUE_HIGH) }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability sensitivityAdjustment Medium should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.sensitivityAdjustment", component = "main", command = "setSensitivityAdjustment", args = {"Medium"} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:write(mock_device, PREF_SENSITIVITY_VALUE_MEDIUM) }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability sensitivityAdjustment Low should be handled", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "stse.sensitivityAdjustment", component = "main", command = "setSensitivityAdjustment", args = {"Low"} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:write(mock_device, PREF_SENSITIVITY_VALUE_LOW) }) + test.socket.zigbee:__expect_send({ mock_device.id, + IASZone.attributes.CurrentZoneSensitivityLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_samjin_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_samjin_sensor.lua index d966d301f3..2e098b3e69 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_samjin_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_samjin_sensor.lua @@ -93,7 +93,10 @@ test.register_coroutine_test( -- zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, IASZone.ID) -- }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -115,7 +118,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_sengled_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_sengled_motion.lua index 4450761c5d..9f4c74dc30 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_sengled_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_sengled_motion.lua @@ -44,7 +44,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 0xFFFF, 0x0000, 0) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -93,7 +96,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartsense_motion_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartsense_motion_sensor.lua index 007c8448bf..c6e4d022e6 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartsense_motion_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartsense_motion_sensor.lua @@ -61,13 +61,16 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, - build_motion_status_message(mock_device, "|") + build_motion_status_message(mock_device, "\x7C") }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -75,13 +78,16 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, - build_motion_status_message(mock_device, "~") + build_motion_status_message(mock_device, "\x7E") }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -89,13 +95,16 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, - build_motion_status_message(mock_device, "X") + build_motion_status_message(mock_device, "\x58") }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(70))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -103,13 +112,16 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, - build_motion_status_message(mock_device, "Z") + build_motion_status_message(mock_device, "\x5A") }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(70))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -117,12 +129,15 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, - build_motion_status_message(mock_device, "½") + build_motion_status_message(mock_device, "\xBD") }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -130,12 +145,15 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, - build_motion_status_message(mock_device, "¿") + build_motion_status_message(mock_device, "\xBF") }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.active())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -143,13 +161,16 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, - build_motion_status_message(mock_device, "0") + build_motion_status_message(mock_device, "\x30") }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(0))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({ value = -50, unit = "dBm" }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -159,7 +180,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.lqi(0))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.signalStrength.rssi({value = -100, unit = 'dBm'}))) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartthings_motion.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartthings_motion.lua index 5211ce1982..0f6c4039e2 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartthings_motion.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_smartthings_motion.lua @@ -53,7 +53,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_thirdreality_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_thirdreality_sensor.lua index ddeaf77ce4..45328961e9 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_thirdreality_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_thirdreality_sensor.lua @@ -68,7 +68,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device1:generate_test_message("main", capabilities.battery.battery(55)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -91,7 +94,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device1:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -114,7 +120,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device2:generate_test_message("main", capabilities.battery.battery(55)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -137,7 +146,32 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device2:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Handle added lifecycle - reads ApplicationVersion", + { + { + channel = "device_lifecycle", + direction = "receive", + message = {mock_device1.id, "added"} + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device1.id, + Basic.attributes.ApplicationVersion:read(mock_device1) + } + } + }, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_iris.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_iris.lua index f0cd1053dc..5794245167 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_iris.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_iris.lua @@ -54,7 +54,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -139,7 +142,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_nyce.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_nyce.lua index e368e852c8..01e7785287 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_nyce.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_nyce.lua @@ -44,6 +44,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -60,6 +63,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -86,7 +92,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } + ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_orvibo.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_orvibo.lua index 1f586bce88..82f1b9509b 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_orvibo.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_motion_orvibo.lua @@ -44,6 +44,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -60,6 +63,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -76,6 +82,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -92,6 +101,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -116,7 +128,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -140,7 +155,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_plugin_motion_sensor.lua b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_plugin_motion_sensor.lua index 78634bbaa2..e93a03b861 100644 --- a/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_plugin_motion_sensor.lua +++ b/drivers/SmartThings/zigbee-motion-sensor/src/test/test_zigbee_plugin_motion_sensor.lua @@ -47,6 +47,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -63,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -107,6 +113,9 @@ test.register_message_test( OccupancySensing.attributes.Occupancy:read(mock_device) } }, + }, + { + min_api_version = 14 } ) @@ -128,7 +137,10 @@ test.register_coroutine_test( ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-power-meter/fingerprints.yml b/drivers/SmartThings/zigbee-power-meter/fingerprints.yml index ec58259dd5..2948c0a381 100644 --- a/drivers/SmartThings/zigbee-power-meter/fingerprints.yml +++ b/drivers/SmartThings/zigbee-power-meter/fingerprints.yml @@ -7,12 +7,27 @@ zigbeeManufacturer: deviceLabel: frient Energy Monitor manufacturer: Develco model: "ZHEMI101" - deviceProfileName: power-meter + deviceProfileName: frient-power-energy-consumption-report - id: "Develco/EMIZB-132" deviceLabel: frient Energy Monitor manufacturer: Develco Products A/S model: "EMIZB-132" - deviceProfileName: power-meter + deviceProfileName: frient-power-meter-consumption-report + - id: "frient A/S/EMIZB-132" + deviceLabel: frient Energy Monitor + manufacturer: frient A/S + model: "EMIZB-132" + deviceProfileName: frient-power-meter-consumption-report + - id: "frient A/S/EMIZB-141" + deviceLabel: "frient EMI 2 LED" + manufacturer: frient A/S + model: "EMIZB-141" + deviceProfileName: frient-power-energy-battery-consumption-report + - id: "frient A/S/EMIZB-151" + deviceLabel: "frient EMI 2 P1" + manufacturer: frient A/S + model: "EMIZB-151" + deviceProfileName: frient-power-energy-current-voltage - id: "ShinaSystem/PMM-300Z1" deviceLabel: SiHAS Energy Monitor manufacturer: ShinaSystem @@ -68,6 +83,31 @@ zigbeeManufacturer: manufacturer: BITUO TECHNIK model: "SDM01B" deviceProfileName: power-meter-1p + - id: "Zemismart/SPM01-1Z2" + deviceLabel: Energy Monitor 1PN + manufacturer: Zemismart + model: "SPM01-1Z2" + deviceProfileName: power-meter-1p + - id: "Zemismart/SDM02-2Z1" + deviceLabel: Energy Monitor 2PN + manufacturer: Zemismart + model: "SDM02-2Z1" + deviceProfileName: power-meter-2p + - id: "Zemismart/SPM02-3Z3" + deviceLabel: Energy Monitor 3PN + manufacturer: Zemismart + model: "SPM02-3Z3" + deviceProfileName: power-meter-3p + - id: "Zemismart/SDM01-3Z1" + deviceLabel: Energy Monitor 3PN + manufacturer: Zemismart + model: "SDM01-3Z1" + deviceProfileName: power-meter-3p + - id: "Zemismart/SDM01-1Z1" + deviceLabel: Energy Monitor 1PN + manufacturer: Zemismart + model: "SDM01-1Z1" + deviceProfileName: power-meter-1p zigbeeGeneric: - id: "genericMeter" deviceLabel: Zigbee Meter diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-battery-consumption-report.yml b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-battery-consumption-report.yml new file mode 100644 index 0000000000..03278796a9 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-battery-consumption-report.yml @@ -0,0 +1,37 @@ +name: frient-power-energy-battery-consumption-report +components: + - id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - title: "Pulse Configuration" + name: pulseConfiguration + description: "Number of pulses the meter outputs per unit" + required: false + preferenceType: integer + definition: + minimum: 50 + maximum: 10000 + default: 1000 + - title: "Initial Energy Consumption" + name: currentSummation + description: "Offset (scaled value) for current summation delivered" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 268435455 + default: 0 diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-consumption-report.yml b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-consumption-report.yml new file mode 100644 index 0000000000..c4ac75361b --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-consumption-report.yml @@ -0,0 +1,35 @@ +name: frient-power-energy-consumption-report +components: + - id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - title: "Pulse Configuration" + name: pulseConfiguration + description: "Number of pulses the meter outputs per unit" + required: false + preferenceType: integer + definition: + minimum: 50 + maximum: 10000 + default: 1000 + - title: "Initial Energy Consumption" + name: currentSummation + description: "Offset (scaled value) for current summation delivered" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 268435455 + default: 0 diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-current-voltage.yml b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-current-voltage.yml new file mode 100644 index 0000000000..b4464d4ada --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-energy-current-voltage.yml @@ -0,0 +1,48 @@ +name: frient-power-energy-current-voltage +components: + - id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor + - id: production + label: Production + capabilities: + - id: energyMeter + version: 1 + - id: phaseA + label: "Phase A" + capabilities: + - id: powerMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: phaseB + label: "Phase B" + capabilities: + - id: powerMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 + - id: phaseC + label: "Phase C" + capabilities: + - id: powerMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: currentMeasurement + version: 1 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-meter-consumption-report.yml b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-meter-consumption-report.yml new file mode 100644 index 0000000000..f65fce5d42 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/profiles/frient-power-meter-consumption-report.yml @@ -0,0 +1,16 @@ +name: frient-power-meter-consumption-report +components: +- id: main + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/bituo/fingerprints.lua b/drivers/SmartThings/zigbee-power-meter/src/bituo/fingerprints.lua index 3909881854..4f4a659189 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/bituo/fingerprints.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/bituo/fingerprints.lua @@ -9,7 +9,12 @@ local ZIGBEE_POWER_METER_FINGERPRINTS = { { mfr = "BITUO TECHNIK", model = "SPM02-E0" }, { mfr = "BITUO TECHNIK", model = "SPM02X" }, { mfr = "BITUO TECHNIK", model = "SDM01W" }, - { mfr = "BITUO TECHNIK", model = "SDM01B" } + { mfr = "BITUO TECHNIK", model = "SDM01B" }, + { mfr = "Zemismart", model = "SPM01-1Z2" }, + { mfr = "Zemismart", model = "SPM02-3Z3" }, + { mfr = "Zemismart", model = "SDM01-3Z1" }, + { mfr = "Zemismart", model = "SDM01-1Z1" }, + { mfr = "Zemismart", model = "SDM02-2Z1" } } return ZIGBEE_POWER_METER_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-power-meter/src/bituo/init.lua b/drivers/SmartThings/zigbee-power-meter/src/bituo/init.lua index 4bf5dbd359..93788ddfb6 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/bituo/init.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/bituo/init.lua @@ -178,13 +178,13 @@ local device_init = function(self, device) device:add_configured_attribute(attribute) device:add_monitored_attribute(attribute) end - if string.find(device:get_model(), "SDM02") or string.find(device:get_model(), "SPM02") or string.find(device:get_model(), "SDM01W") then + if string.find(device:get_model(), "SDM02") or string.find(device:get_model(), "SPM02") or string.find(device:get_model(), "SDM01W") or string.find(device:get_model(), "SDM01-3Z1", 1, true) then for _, attribute in ipairs(PHASE_B_CONFIGURATION) do device:add_configured_attribute(attribute) device:add_monitored_attribute(attribute) end end - if string.find(device:get_model(), "SPM02") or string.find(device:get_model(), "SDM01W") then + if string.find(device:get_model(), "SPM02") or string.find(device:get_model(), "SDM01W") or string.find(device:get_model(), "SDM01-3Z1", 1, true) then for _, attribute in ipairs(PHASE_C_CONFIGURATION) do device:add_configured_attribute(attribute) device:add_monitored_attribute(attribute) diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/can_handle.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/can_handle.lua new file mode 100644 index 0000000000..85ee8c086f --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_frient_power_meter = function(opts, driver, device, zb_rx) + local FINGERPRINTS = require("frient.EMIZB-151.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return true, require("frient.EMIZB-151") + end + end + + return false +end + +return is_frient_power_meter diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/fingerprints.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/fingerprints.lua new file mode 100644 index 0000000000..e6af73d53e --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_POWER_METER_FINGERPRINTS = { + { model = "EMIZB-151"} +} + +return ZIGBEE_POWER_METER_FINGERPRINTS \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/init.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/init.lua new file mode 100644 index 0000000000..e7b2bae204 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/EMIZB-151/init.lua @@ -0,0 +1,258 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zigbee_constants = require "st.zigbee.constants" +local capabilities = require "st.capabilities" + +local clusters = require "st.zigbee.zcl.clusters" +local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local utils = require "frient.utils" + +local data_types = require "st.zigbee.data_types" +local LAST_REPORT_TIME = "LAST_REPORT_TIME" +local SIMPLE_METERING_DEFAULT_DIVISOR = 1000 + +local AC_VOLTAGE_MULTIPLIER_KEY = "_electrical_measurement_ac_voltage_multiplier" +local AC_CURRENT_MULTIPLIER_KEY = "_electrical_measurement_ac_current_multiplier" +local AC_VOLTAGE_DIVISOR_KEY = "_electrical_measurement_ac_voltage_divisor" +local AC_CURRENT_DIVISOR_KEY = "_electrical_measurement_ac_current_divisor" + +local CurrentSummationReceived = 0x0001 + +local ATTRIBUTES = { + { + cluster = SimpleMetering.ID, + attribute = CurrentSummationReceived, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint48, + reportable_change = 1 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.ActivePowerPhB.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Int16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.ActivePowerPhC.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Int16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltage.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltagePhB.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSVoltagePhC.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrent.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrentPhB.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + }, + { + cluster = ElectricalMeasurement.ID, + attribute = ElectricalMeasurement.attributes.RMSCurrentPhC.ID, + minimum_interval = 5, + maximum_interval = 3600, + data_type = data_types.Uint16, + reportable_change = 5 + } +} + +local device_init = function(self, device) + for _, attribute in ipairs(ATTRIBUTES) do + device:add_configured_attribute(attribute) + end + + if device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == nil then + device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, SIMPLE_METERING_DEFAULT_DIVISOR, { persist = true }) + end +end + +local do_configure = function(self, device) + device:refresh() + device:configure() + + -- Divisor and multipler for PowerMeter + device:send(SimpleMetering.attributes.Divisor:read(device)) + device:send(SimpleMetering.attributes.Multiplier:read(device)) +end + +local instantaneous_demand_handler = function(driver, device, value, zb_rx) + local raw_value = value.value + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or SIMPLE_METERING_DEFAULT_DIVISOR + + raw_value = raw_value * multiplier / divisor * 1000 + + -- The result is already in watts, no need to multiply by 1000 + device:emit_event(capabilities.powerMeter.power({ value = raw_value, unit = "W" })) +end + +local current_summation_delivered_handler = function(driver, device, value, zb_rx) + local raw_value = value.value + + -- Handle potential overflow values + if raw_value < 0 or raw_value >= 0xFFFFFFFFFFFF then + return + end + + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or SIMPLE_METERING_DEFAULT_DIVISOR + + raw_value = raw_value * multiplier / divisor * 1000 + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.energyMeter.energy({ value = raw_value, unit = "Wh" })) + + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) + end + + local current_time = os.time() + local last_report_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_report_time = last_report_time + 60 * 15 -- 15 mins, the minimum interval allowed between reports + if current_time < next_report_time then + return + end + + device:emit_event_for_endpoint( + zb_rx.address_header.src_endpoint.value, + capabilities.powerConsumptionReport.powerConsumption({ + start = utils.epoch_to_iso8601(last_report_time), + ["end"] = utils.epoch_to_iso8601(current_time - 1), + deltaEnergy = delta_energy, + energy = raw_value + }) + ) + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) +end + +local current_summation_received_handler = function(driver, device, value, zb_rx) + local raw_value = value.value + + -- Handle potential overflow values + if raw_value < 0 or raw_value >= 0xFFFFFFFFFFFF then + return + end + + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or 1000 + + raw_value = raw_value * multiplier / divisor * 1000 + device:emit_component_event(device.profile.components['production'], capabilities.energyMeter.energy({ value = raw_value, unit = "Wh" })) +end + +local electrical_measurement_ac_voltage_multiplier_handler = function(driver, device, multiplier, zb_rx) + local raw_value = multiplier.value + device:set_field(AC_VOLTAGE_MULTIPLIER_KEY, raw_value, { persist = true }) +end + +local electrical_measurement_ac_voltage_divisor_handler = function(driver, device, divisor, zb_rx) + local raw_value = divisor.value + if raw_value == 0 then + return + end + device:set_field(AC_VOLTAGE_DIVISOR_KEY, raw_value, { persist = true }) +end + +local electrical_measurement_ac_current_multiplier_handler = function(driver, device, multiplier, zb_rx) + local raw_value = multiplier.value + device:set_field(AC_CURRENT_MULTIPLIER_KEY, raw_value, { persist = true }) +end + +local electrical_measurement_ac_current_divisor_handler = function(driver, device, divisor, zb_rx) + local raw_value = divisor.value + if raw_value == 0 then + return + end + device:set_field(AC_CURRENT_DIVISOR_KEY, raw_value, { persist = true }) +end + +local measurement_handler = function(component, multiplier_key, divisor_key, emit_fn, unit) + local handler = function(driver, device, value, zb_rx) + local raw_value = value.value + -- By default emit raw value + local multiplier = device:get_field(multiplier_key) or 1 + local divisor = device:get_field(divisor_key) or 1 + + raw_value = raw_value * multiplier / divisor + + device:emit_component_event(device.profile.components[component], emit_fn({ value = raw_value, unit = unit })) + end + + return handler +end + +local frient_emi = { + NAME = "EMIZB-151", + lifecycle_handlers = { + init = device_init, + doConfigure = do_configure + }, + zigbee_handlers = { + cluster = { + }, + attr = { + [SimpleMetering.ID] = { + [CurrentSummationReceived] = current_summation_received_handler, + [SimpleMetering.attributes.CurrentSummationDelivered.ID] = current_summation_delivered_handler, + [SimpleMetering.attributes.InstantaneousDemand.ID] = instantaneous_demand_handler, + }, + [ElectricalMeasurement.ID] = { + [ElectricalMeasurement.attributes.ACVoltageDivisor.ID] = electrical_measurement_ac_voltage_divisor_handler, + [ElectricalMeasurement.attributes.ACVoltageMultiplier.ID] = electrical_measurement_ac_voltage_multiplier_handler, + [ElectricalMeasurement.attributes.ACCurrentDivisor.ID] = electrical_measurement_ac_current_divisor_handler, + [ElectricalMeasurement.attributes.ACCurrentMultiplier.ID] = electrical_measurement_ac_current_multiplier_handler, + [ElectricalMeasurement.attributes.ActivePower.ID] = measurement_handler("phaseA", zigbee_constants.ELECTRICAL_MEASUREMENT_MULTIPLIER_KEY, zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, capabilities.powerMeter.power, "W"), + [ElectricalMeasurement.attributes.RMSVoltage.ID] = measurement_handler("phaseA", AC_VOLTAGE_MULTIPLIER_KEY, AC_VOLTAGE_DIVISOR_KEY, capabilities.voltageMeasurement.voltage, "V"), + [ElectricalMeasurement.attributes.RMSCurrent.ID] = measurement_handler("phaseA", AC_CURRENT_MULTIPLIER_KEY, AC_CURRENT_DIVISOR_KEY, capabilities.currentMeasurement.current, "A"), + [ElectricalMeasurement.attributes.ActivePowerPhB.ID] = measurement_handler("phaseB", zigbee_constants.ELECTRICAL_MEASUREMENT_MULTIPLIER_KEY, zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, capabilities.powerMeter.power, "W"), + [ElectricalMeasurement.attributes.RMSVoltagePhB.ID] = measurement_handler("phaseB", AC_VOLTAGE_MULTIPLIER_KEY, AC_VOLTAGE_DIVISOR_KEY, capabilities.voltageMeasurement.voltage, "V"), + [ElectricalMeasurement.attributes.RMSCurrentPhB.ID] = measurement_handler("phaseB", AC_CURRENT_MULTIPLIER_KEY, AC_CURRENT_DIVISOR_KEY, capabilities.currentMeasurement.current, "A"), + [ElectricalMeasurement.attributes.ActivePowerPhC.ID] = measurement_handler("phaseC", zigbee_constants.ELECTRICAL_MEASUREMENT_MULTIPLIER_KEY, zigbee_constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, capabilities.powerMeter.power, "W"), + [ElectricalMeasurement.attributes.RMSVoltagePhC.ID] = measurement_handler("phaseC", AC_VOLTAGE_MULTIPLIER_KEY, AC_VOLTAGE_DIVISOR_KEY, capabilities.voltageMeasurement.voltage, "V"), + [ElectricalMeasurement.attributes.RMSCurrentPhC.ID] = measurement_handler("phaseC", AC_CURRENT_MULTIPLIER_KEY, AC_CURRENT_DIVISOR_KEY, capabilities.currentMeasurement.current, "A") + } + } + }, + can_handle = require("frient.EMIZB-151.can_handle") +} + +return frient_emi \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/fingerprints.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/fingerprints.lua index 5bc09f600d..1e00c4434e 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/frient/fingerprints.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/fingerprints.lua @@ -2,8 +2,10 @@ -- Licensed under the Apache License, Version 2.0 local ZIGBEE_POWER_METER_FINGERPRINTS = { - { model = "ZHEMI101" }, - { model = "EMIZB-132" }, + { model = "ZHEMI101", }, + { model = "EMIZB-132", }, + { model = "EMIZB-141", MIN_BAT = 2.3 , MAX_BAT = 3.0 }, + { model = "EMIZB-151", } } return ZIGBEE_POWER_METER_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/init.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/init.lua index 5933faf5cb..e87e3f909d 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/init.lua @@ -1,28 +1,184 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local zigbee_constants = require "st.zigbee.constants" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" -local constants = require "st.zigbee.constants" -local configurations = require "configurations" +local clusters = require "st.zigbee.zcl.clusters" +local SimpleMetering = clusters.SimpleMetering +local PowerConfiguration = clusters.PowerConfiguration +local utils = require "frient.utils" +local LAST_REPORT_TIME = "LAST_REPORT_TIME" +local data_types = require "st.zigbee.data_types" -local do_configure = function(self, device) +local log = require "log" +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local SIMPLE_METERING_DEFAULT_DIVISOR = 1000 + +local ZIGBEE_POWER_METER_FINGERPRINTS = require("frient.fingerprints") + +local device_init = function(self, device) + for _, fingerprint in ipairs(ZIGBEE_POWER_METER_FINGERPRINTS) do + if device:get_model() == fingerprint.model and fingerprint.MIN_BAT then + battery_defaults.build_linear_voltage_init(fingerprint.MIN_BAT, fingerprint.MAX_BAT)(self, device) + end + end +end + +local do_refresh = function(self, device) device:refresh() + if device:supports_capability(capabilities.battery) then + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + end +end + +local do_configure = function(self, device) device:configure() + for _, fingerprint in ipairs(ZIGBEE_POWER_METER_FINGERPRINTS) do + if device:get_model() == fingerprint.model and device.preferences then + -- Only write manufacturer-specific attributes when preferences exist for this device. + if device.preferences.pulseConfiguration ~= nil then + local pulseConfiguration = tonumber(device.preferences.pulseConfiguration) or 1000 + device:send(cluster_base.write_manufacturer_specific_attribute(device, SimpleMetering.ID, 0x0300, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, pulseConfiguration):to_endpoint(0x02)) + end + + if device.preferences.currentSummation ~= nil then + local currentSummation = tonumber(device.preferences.currentSummation) or 0 + device:send(cluster_base.write_manufacturer_specific_attribute(device, SimpleMetering.ID, 0x0301, DEVELCO_MANUFACTURER_CODE, data_types.Uint48, currentSummation):to_endpoint(0x02)) + end + end + end + + -- Divisor and multipler for PowerMeter + device:send(SimpleMetering.attributes.Divisor:read(device)) + device:send(SimpleMetering.attributes.Multiplier:read(device)) + + device.thread:call_with_delay(5, function() + do_refresh(self, device) + end) end -local device_init = function(self, device) - device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) - device:set_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10000, {persist = true}) +local function info_changed(driver, device, event, args) + for name, value in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + if (name == "pulseConfiguration") then + local pulseConfiguration = tonumber(device.preferences.pulseConfiguration) + device:send(cluster_base.write_manufacturer_specific_attribute(device, SimpleMetering.ID, 0x0300, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, pulseConfiguration):to_endpoint(0x02)) + end + if (name == "currentSummation") then + local currentSummation = tonumber(device.preferences.currentSummation) + device:send(cluster_base.write_manufacturer_specific_attribute(device, SimpleMetering.ID, 0x0301, DEVELCO_MANUFACTURER_CODE, data_types.Uint48, currentSummation):to_endpoint(0x02)) + end + end + end + device.thread:call_with_delay(5, function() + do_refresh(driver, device) + end) +end + +local function simple_metering_divisor_handler(driver, device, divisor, zb_rx) + local new_divisor = SIMPLE_METERING_DEFAULT_DIVISOR + local header = zb_rx.body and zb_rx.body.zcl_header + if header and header.frame_ctrl:is_mfg_specific_set() then + log.debug_with({ hub_logs = true }, string.format("Ignoring manufacturer-specific divisor report: %s", tostring(divisor.value))) + elseif (divisor.value and divisor.value == 0) then + log.warn_with({ hub_logs = true }, "Simple metering divisor reported as 0; forcing divisor to 1000") + elseif (divisor.value and divisor.value > 0) then + new_divisor = divisor.value + end + device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, new_divisor, { persist = true }) +end + +local function instantaneous_demand_handler(driver, device, value, zb_rx) + local raw_value = value.value + --- demand = demand received * Multipler/Divisor + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or SIMPLE_METERING_DEFAULT_DIVISOR + if raw_value < -8388607 or raw_value >= 8388607 then + raw_value = 0 + end + + raw_value = raw_value * multiplier / divisor * 1000 + + local raw_value_watts = raw_value + device:emit_event(capabilities.powerMeter.power({ value = raw_value_watts, unit = "W" })) +end + +local function energy_meter_handler(driver, device, value, zb_rx) + local raw_value = value.value + local multiplier = device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) or 1 + local divisor = device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) or SIMPLE_METERING_DEFAULT_DIVISOR + + if raw_value < 0 or raw_value >= 0xFFFFFFFFFFFF then + return + end + + raw_value = (raw_value * multiplier) / divisor + + local offset = device:get_field(zigbee_constants.ENERGY_METER_OFFSET) or 0 + if raw_value < offset then + --- somehow our value has gone below the offset, so we'll reset the offset, since the device seems to have + offset = 0 + device:set_field(zigbee_constants.ENERGY_METER_OFFSET, offset, { persist = true }) + end + raw_value = raw_value - offset + raw_value = raw_value * 1000 -- the unit of these values should be 'Wh' + + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, capabilities.energyMeter.energy({ value = raw_value, unit = "Wh" })) + + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) + end + + local current_time = os.time() + local last_report_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_report_time = last_report_time + 60 * 15 -- 15 mins, the minimum interval allowed between reports + if current_time < next_report_time then + return + end + + device:emit_event_for_endpoint( + zb_rx.address_header.src_endpoint.value, + capabilities.powerConsumptionReport.powerConsumption({ + start = utils.epoch_to_iso8601(last_report_time), + ["end"] = utils.epoch_to_iso8601(current_time - 1), + deltaEnergy = delta_energy, + energy = raw_value + }) + ) + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) end local frient_power_meter_handler = { NAME = "frient power meter handler", lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(device_init), + init = device_init, doConfigure = do_configure, + infoChanged = info_changed + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zigbee_handlers = { + cluster = { + }, + attr = { + [SimpleMetering.ID] = { + [SimpleMetering.attributes.CurrentSummationDelivered.ID] = energy_meter_handler, + [SimpleMetering.attributes.InstantaneousDemand.ID] = instantaneous_demand_handler, + [SimpleMetering.attributes.Divisor.ID] = simple_metering_divisor_handler + } + } }, + sub_drivers = require("frient.sub_drivers"), can_handle = require("frient.can_handle"), } diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/sub_drivers.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/sub_drivers.lua new file mode 100644 index 0000000000..3698af85d7 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient.EMIZB-151") +} + +return sub_drivers diff --git a/drivers/SmartThings/zigbee-power-meter/src/frient/utils.lua b/drivers/SmartThings/zigbee-power-meter/src/frient/utils.lua new file mode 100644 index 0000000000..70e7684855 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/frient/utils.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local utils = {} + +utils.epoch_to_iso8601 = function(time) + return os.date("!%Y-%m-%dT%H:%M:%SZ", time) +end + +return utils \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-power-meter/src/init.lua b/drivers/SmartThings/zigbee-power-meter/src/init.lua index f15fae7905..4f69f0e72e 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/init.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/init.lua @@ -41,6 +41,7 @@ local zigbee_power_meter_driver_template = { capabilities.powerMeter, capabilities.energyMeter, capabilities.powerConsumptionReport, + capabilities.battery, }, zigbee_handlers = { global = { @@ -59,6 +60,7 @@ local zigbee_power_meter_driver_template = { doConfigure = do_configure, }, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_power_meter_driver_template, zigbee_power_meter_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_battery_consumption_report.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_battery_consumption_report.lua new file mode 100644 index 0000000000..d752ccd553 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_battery_consumption_report.lua @@ -0,0 +1,302 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-power-energy-battery-consumption-report.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + model = "EMIZB-141", + server_clusters = { ElectricalMeasurement.ID, PowerConfiguration.ID, SimpleMetering.ID } + } + } + } +) + + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "InstantaneousDemand Report should be handled.", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }, + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 2700) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 2700.0, unit = "W" })) + } + } +) + +test.register_coroutine_test( + "lifecycle configure event should configure the device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SimpleMetering.ID, 0x0300, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 1000):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SimpleMetering.ID, 0x0301, DEVELCO_MANUFACTURER_CODE, data_types.Uint48, 0):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting( + mock_device, 5, 3600, 1 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryVoltage:configure_reporting( + mock_device, 30, 21600, 1 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +, + { + min_api_version = 15 + }) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }} + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: pulseConfiguration, currentSummation", + function() + local updates = { + preferences = { + pulseConfiguration = 400, + currentSummation = 500 + } + } + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SimpleMetering.ID, + 0x0300, + DEVELCO_MANUFACTURER_CODE, + data_types.Uint16, + 400 + ):to_endpoint(0x02) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SimpleMetering.ID, + 0x0301, + DEVELCO_MANUFACTURER_CODE, + data_types.Uint48, + 500 + ):to_endpoint(0x02) + }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered Report should be handled.", + function() + local current_time = os.time() - 60 * 16 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1969-12-31T23:44:00Z", + ["end"] = "1969-12-31T23:59:59Z", + deltaEnergy = 0.0, + energy = 2700.0 + }) + ) + ) + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered report should be handled without powerConsumptionReport because 15 min didn't pass since last report", + function() + local current_time = os.time() - 60 * 14 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_consumption_report.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_consumption_report.lua new file mode 100644 index 0000000000..ae8ad437d9 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_consumption_report.lua @@ -0,0 +1,275 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-power-energy-consumption-report.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + model = "ZHEMI101", + server_clusters = { ElectricalMeasurement.ID, PowerConfiguration.ID, SimpleMetering.ID } + } + } + } +) + + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "InstantaneousDemand Report should be handled.", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) } + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }, + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 2700) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 2700.0, unit = "W" })) + } + } +) + +test.register_coroutine_test( + "lifecycle configure event should configure the device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SimpleMetering.ID, 0x0300, DEVELCO_MANUFACTURER_CODE, data_types.Uint16, 1000):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SimpleMetering.ID, 0x0301, DEVELCO_MANUFACTURER_CODE, data_types.Uint48, 0):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting( + mock_device, 5, 3600, 1 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +, + { + min_api_version = 15 + }) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }} + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_coroutine_test( + "infochanged to check for necessary preferences settings: pulseConfiguration, currentSummation", + function() + local updates = { + preferences = { + pulseConfiguration = 400, + currentSummation = 500 + } + } + + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SimpleMetering.ID, + 0x0300, + DEVELCO_MANUFACTURER_CODE, + data_types.Uint16, + 400 + ):to_endpoint(0x02) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SimpleMetering.ID, + 0x0301, + DEVELCO_MANUFACTURER_CODE, + data_types.Uint48, + 500 + ):to_endpoint(0x02) + }) + + test.socket.zigbee:__set_channel_ordering("relaxed") + + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered Report should be handled.", + function() + local current_time = os.time() - 60 * 16 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1969-12-31T23:44:00Z", + ["end"] = "1969-12-31T23:59:59Z", + deltaEnergy = 0.0, + energy = 2700.0 + }) + ) + ) + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered report should be handled without powerConsumptionReport because 15 min didn't pass since last report", + function() + local current_time = os.time() - 60 * 14 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_current_voltage.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_current_voltage.lua new file mode 100644 index 0000000000..a6edbf71bd --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_frient_power_energy_current_voltage.lua @@ -0,0 +1,364 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local CurrentSummationReceived = 0x0001 +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local zigbee_constants = require "st.zigbee.constants" +zigbee_constants.ELECTRICAL_MEASUREMENT_AC_VOLTAGE_MULTIPLIER_KEY = "_electrical_measurement_ac_voltage_multiplier" +zigbee_constants.ELECTRICAL_MEASUREMENT_AC_CURRENT_MULTIPLIER_KEY = "_electrical_measurement_ac_current_multiplier" +zigbee_constants.ELECTRICAL_MEASUREMENT_AC_VOLTAGE_DIVISOR_KEY = "_electrical_measurement_ac_voltage_divisor" +zigbee_constants.ELECTRICAL_MEASUREMENT_AC_CURRENT_DIVISOR_KEY = "_electrical_measurement_ac_current_divisor" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-power-energy-current-voltage.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + model = "EMIZB-151", + server_clusters = { ElectricalMeasurement.ID, SimpleMetering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +local function expected_refresh_commands() + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute( + mock_device, + data_types.ClusterId(SimpleMetering.ID), + CurrentSummationReceived + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:read(mock_device) + }) +end + + + + +test.register_coroutine_test( + "Refresh should read all necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", command = "refresh", args = {} } }) + + expected_refresh_commands() + end +) + +test.register_coroutine_test( + "ALl reports (for all phases) should be handled properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ACVoltageMultiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ACVoltageDivisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ACCurrentMultiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ACCurrentDivisor:build_test_attr_report(mock_device, 1000) }) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 30) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 30.0, unit = "Wh"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 40) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 40.0, unit = "W"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, 50) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseA", capabilities.powerMeter.power({ value = 50.0, unit = "W"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, 50) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseA", capabilities.voltageMeasurement.voltage({ value = 0.05, unit = "V"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, 60) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseA", capabilities.currentMeasurement.current({ value = 0.06, unit = "A"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhB:build_test_attr_report(mock_device, 70) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseB", capabilities.powerMeter.power({ value = 70.0, unit = "W"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhB:build_test_attr_report(mock_device, 80) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseB", capabilities.voltageMeasurement.voltage({ value = 0.08, unit = "V"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhB:build_test_attr_report(mock_device, 90) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseB", capabilities.currentMeasurement.current({ value = 0.09, unit = "A"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhC:build_test_attr_report(mock_device, 100) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseC", capabilities.powerMeter.power({ value = 100.0, unit = "W"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhC:build_test_attr_report(mock_device, 110) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseC", capabilities.voltageMeasurement.voltage({ value = 0.11, unit = "V"})) + ) + + test.socket.zigbee:__queue_receive({ mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhC:build_test_attr_report(mock_device, 120) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("phaseC", capabilities.currentMeasurement.current({ value = 0.12, unit = "A"})) + ) + + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered Report should be handled.", + function() + local current_time = os.time() - 60 * 16 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1969-12-31T23:44:00Z", + ["end"] = "1969-12-31T23:59:59Z", + deltaEnergy = 0.0, + energy = 2700.0 + }) + ) + ) + end +) + +test.register_coroutine_test( + "CurrentSummationDelivered report should be handled without powerConsumptionReport because 15 min didn't pass since last report", + function() + local current_time = os.time() - 60 * 14 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + end +) + +test.register_coroutine_test( + "lifecycle configure event should configure the device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + expected_refresh_commands() + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting( + mock_device, 1, 43200, 1 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting( + mock_device, 5, 3600, 5 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting( + mock_device, 5, 3600, 1 + ) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting( + mock_device, + data_types.ClusterId(SimpleMetering.ID), + data_types.AttributeId(CurrentSummationReceived), + data_types.ZigbeeDataType(data_types.Uint48.ID), + 5, + 3600, + 1 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +, + { + min_api_version = 15 + }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter.lua index 5f96805360..19ad41c700 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter.lua @@ -90,7 +90,8 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) @@ -116,7 +117,8 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) @@ -141,7 +143,8 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) @@ -166,7 +169,8 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) @@ -189,6 +193,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 2.7, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -210,6 +217,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.027, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -279,7 +289,10 @@ test.register_coroutine_test( SimpleMetering.attributes.Divisor:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p.lua index 5308e11ee2..354cffe1e2 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local ElectricalMeasurement = clusters.ElectricalMeasurement @@ -7,6 +10,7 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" +local constants = require "st.zigbee.constants" -- Mock out globals @@ -70,7 +74,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -87,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -104,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -121,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) } + }, + { + min_api_version = 14 } ) @@ -138,6 +154,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) } + }, + { + min_api_version = 14 } ) @@ -219,7 +238,118 @@ test.register_coroutine_test( ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } +) + +test.register_message_test( + "resetEnergyMeter command should send OnOff On to reset device", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "energyMeter", component = "main", command = "resetEnergyMeter", args = {} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, clusters.OnOff.server.commands.On(mock_device) } + } + }, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.register_coroutine_test( + "refresh capability command should read device attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "energy handler resets offset when reading is below stored offset", + function() + -- Set an offset larger than the incoming value (100 raw / 100 = 1.0 kWh, offset = 5.0) + mock_device:set_field(constants.ENERGY_METER_OFFSET, 5.0, {persist = true}) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 100) + }) + -- Offset resets to 0; raw_value_kilowatts = 1.0 - 0 = 1.0; no powerConsumptionReport (delta_tick < 15 min) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.0, unit = "kWh"})) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "energy handler resets save tick when timer has slipped beyond 30 minutes", + function() + -- Advance time > 30 min so that curr_save_tick + 15*60 < os.time() is true + test.timer.__create_and_queue_test_time_advance_timer(40*60, "oneshot") + test.mock_time.advance_time(40*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 100) + }) + -- raw_value = 100, divisor = 100, kWh = 1.0, watts = 1000.0; first report: deltaEnergy = 0.0 + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1000.0, deltaEnergy = 0.0})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.0, unit = "kWh"})) + ) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p_ZM.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p_ZM.lua new file mode 100644 index 0000000000..530281a13b --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_1p_ZM.lua @@ -0,0 +1,214 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + + +-- Mock out globals +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-1p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Zemismart", + model = "SPM01-1Z2", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +, + { + min_api_version = 15 + }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p.lua index b85c955005..5f9d8a3348 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local ElectricalMeasurement = clusters.ElectricalMeasurement @@ -68,7 +71,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -85,6 +91,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -102,6 +111,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) } + }, + { + min_api_version = 14 } ) @@ -119,6 +131,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) } + }, + { + min_api_version = 14 } ) @@ -136,6 +151,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseB", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -153,6 +171,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseB", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) } + }, + { + min_api_version = 14 } ) @@ -170,6 +191,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseB", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) } + }, + { + min_api_version = 14 } ) @@ -275,7 +299,11 @@ test.register_coroutine_test( SimpleMetering.attributes.InstantaneousDemand:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p_ZM.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p_ZM.lua new file mode 100644 index 0000000000..ffb31f4b64 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_2p_ZM.lua @@ -0,0 +1,287 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +-- 使用两相电能表配置文件 +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-2p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Zemismart", + model = "SDM02-2Z1", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhB:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhB:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhB:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +, + { + min_api_version = 15 + }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p.lua index fad26aad40..1b227dec62 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local ElectricalMeasurement = clusters.ElectricalMeasurement @@ -67,7 +70,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -84,6 +90,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -101,6 +110,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) } + }, + { + min_api_version = 14 } ) @@ -118,6 +130,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) } + }, + { + min_api_version = 14 } ) @@ -135,6 +150,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseB", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -152,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseB", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) } + }, + { + min_api_version = 14 } ) @@ -169,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseB", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) } + }, + { + min_api_version = 14 } ) @@ -186,6 +210,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseC", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -203,6 +230,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseC", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) } + }, + { + min_api_version = 14 } ) @@ -220,6 +250,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("PhaseC", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) } + }, + { + min_api_version = 14 } ) @@ -349,7 +382,11 @@ test.register_coroutine_test( SimpleMetering.attributes.InstantaneousDemand:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p_ZM.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p_ZM.lua new file mode 100644 index 0000000000..8aba874223 --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_3p_ZM.lua @@ -0,0 +1,361 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("power-meter-3p.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Zemismart", + model = "SDM01-3Z1", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "SimpleMetering event should be handled by powerConsumptionReport capability", + function() + test.timer.__create_and_queue_test_time_advance_timer(15*60, "oneshot") + -- #1 : 15 minutes have passed + test.mock_time.advance_time(15*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,150) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + -- #2 : Not even 15 minutes passed + test.wait_for_events() + test.mock_time.advance_time(1*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,170) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.7, unit = "kWh"})) + ) + -- #3 : 15 minutes have passed + test.wait_for_events() + test.mock_time.advance_time(14*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device,200) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 2000.0, deltaEnergy = 500.0 })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) + ) + end +) + +test.register_message_test( + "ActivePower Report should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrent:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseA should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltage:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseA", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_message_test( + "ActivePower Report for PhaseB should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhB:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhB:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseB should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhB:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseB", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_message_test( + "ActivePower Report for PhaseC should be handled. Sensor value is in W, capability attribute value is in hectowatts", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.ActivePowerPhC:build_test_attr_report(mock_device, + 27) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) + } + } +) + +test.register_message_test( + "RMSCurrent Report for PhaseC should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSCurrentPhC:build_test_attr_report(mock_device, + 34) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.currentMeasurement.current({ value = 0.34, unit = "A" })) + } + } +) + +test.register_message_test( + "RMSVoltage Report for PhaseC should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ElectricalMeasurement.attributes.RMSVoltagePhC:build_test_attr_report(mock_device, + 22000) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("PhaseC", capabilities.voltageMeasurement.voltage({ value = 220.0, unit = "V" })) + } + } +) + +test.register_coroutine_test( + "Device configure lifecycle event should configure device properly", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:configure_reporting(mock_device, 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.configure_reporting(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001), data_types.ZigbeeDataType(SimpleMetering.attributes.CurrentSummationDelivered.base_type.ID), 30, 120, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_attribute(mock_device, data_types.ClusterId(SimpleMetering.ID), data_types.AttributeId(0x0001)) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrent:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhB:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePowerPhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSVoltagePhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.RMSCurrentPhC:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +, + { + min_api_version = 15 + }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report_sihas.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report_sihas.lua index a4b8b8c037..b484ac43ee 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report_sihas.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_consumption_report_sihas.lua @@ -26,6 +26,7 @@ local zb_const = require "st.zigbee.constants" local zcl_messages = require "st.zigbee.zcl" local data_types = require "st.zigbee.data_types" local Status = require "st.zigbee.generated.types.ZclStatus" +local constants = require "st.zigbee.constants" local mock_device = test.mock_device.build_test_zigbee_device( @@ -97,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -139,7 +143,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 2.0, unit = "kWh"})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -200,7 +207,10 @@ test.register_coroutine_test( ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -225,9 +235,51 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) +test.register_coroutine_test( + "energy handler resets shinasystems offset when reading is below stored offset", + function() + -- divisor=1000; raw_value=100 -> 0.1 kWh; offset=0.5 -> 0.1 < 0.5 triggers reset + mock_device:set_field(constants.ENERGY_METER_OFFSET, 0.5, {persist = true}) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 100) + }) + -- offset resets to 0; raw_value_kilowatts = 0.1; no powerConsumptionReport (delta_tick < 15 min) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 0.1, unit = "kWh"})) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "shinasystems energy handler resets save tick when timer has slipped beyond 30 minutes", + function() + -- Advance time > 30 min so that curr_save_tick + 15*60 < os.time() is true + test.timer.__create_and_queue_test_time_advance_timer(40*60, "oneshot") + test.mock_time.advance_time(40*60) + test.socket.zigbee:__queue_receive({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 1500) + }) + -- raw_value=1500, divisor=1000, kWh=1.5, watts=1500.0; first report: deltaEnergy=0.0 + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({energy = 1500.0, deltaEnergy = 0.0})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 1.5, unit = "kWh"})) + ) + end, + { + min_api_version = 14 + } +) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_ezex.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_ezex.lua index 7f582d55b7..39a8bc3d43 100644 --- a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_ezex.lua +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_ezex.lua @@ -91,6 +91,9 @@ test.register_message_test( message = { mock_device.id, ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, 27) }, } + }, + { + min_api_version = 14 } ) @@ -127,6 +130,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 0.0015, unit = "kWh"})) } + }, + { + min_api_version = 14 } ) @@ -158,7 +164,10 @@ test.register_coroutine_test( SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -183,7 +192,8 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_frient.lua b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_frient.lua new file mode 100644 index 0000000000..2a4b2db86a --- /dev/null +++ b/drivers/SmartThings/zigbee-power-meter/src/test/test_zigbee_power_meter_frient.lua @@ -0,0 +1,200 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local constants = require "st.zigbee.constants" + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("frient-power-meter-consumption-report.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Develco Products A/S", + model = "EMIZB-132", + server_clusters = {SimpleMetering.ID, ElectricalMeasurement.ID} + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "frient device_init sets divisor fields", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + end +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} }} + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +test.register_coroutine_test( + "frient instantaneous demand report emits power", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:build_test_attr_report(mock_device, 40) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 40.0, unit = "W" })) + ) + end +) + +test.register_coroutine_test( + "frient current summation delivered emits energy and consumption report", + function() + local current_time = os.time() - 60 * 16 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ + start = "1969-12-31T23:44:00Z", + ["end"] = "1969-12-31T23:59:59Z", + deltaEnergy = 0.0, + energy = 2700.0 + }) + ) + ) + end +) + +test.register_coroutine_test( + "frient current summation delivered skips consumption report when interval is short", + function() + local current_time = os.time() - 60 * 14 + mock_device:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 1000) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Multiplier:build_test_attr_report(mock_device, 1) }) + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 2700) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 2700.0, unit = "Wh" })) + ) + end +) + +test.register_coroutine_test( + "frient divisor report updates divisor field", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, SimpleMetering.attributes.Divisor:build_test_attr_report(mock_device, 0) }) + test.wait_for_events() + assert(mock_device:get_field(constants.SIMPLE_METERING_DIVISOR_KEY) == 1000, + "SIMPLE_METERING_DIVISOR_KEY should be 1000") + end +) + +test.register_coroutine_test( + "frient lifecycle configure event should configure device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 15 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-presence-sensor/src/aqara/can_handle.lua new file mode 100644 index 0000000000..33a0bde838 --- /dev/null +++ b/drivers/SmartThings/zigbee-presence-sensor/src/aqara/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_aqara_products = function(opts, driver, device, ...) + local FINGERPRINTS = { mfr = "aqara", model = "lumi.motion.ac01" } + + if device:get_manufacturer() == FINGERPRINTS.mfr and device:get_model() == FINGERPRINTS.model then + return true, require("aqara") + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/aqara/init.lua b/drivers/SmartThings/zigbee-presence-sensor/src/aqara/init.lua index b9a0bfb001..1fd79e3875 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" @@ -19,12 +22,6 @@ local SENSITIVITY = "stse.sensitivity" local RESET_PRESENCE = "stse.resetPresence" local APP_DISTANCE = "stse.approachDistance" -local FINGERPRINTS = { mfr = "aqara", model = "lumi.motion.ac01" } - -local is_aqara_products = function(opts, driver, device, ...) - return device:get_manufacturer() == FINGERPRINTS.mfr and device:get_model() == FINGERPRINTS.model -end - local function device_init(driver, device) -- no action end @@ -104,7 +101,7 @@ local aqara_fp1_handler = { doConfigure = do_configure, infoChanged = device_info_changed }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_fp1_handler diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/can_handle.lua b/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/can_handle.lua new file mode 100644 index 0000000000..d848d46caa --- /dev/null +++ b/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function arrival_sensor_v1_can_handle(opts, driver, device, ...) + -- excluding Aqara device and tagv4 + if device:get_manufacturer() ~= "aqara" and device:get_model() ~= "tagv4" then + return true, require("arrival-sensor-v1") + end + return false +end + +return arrival_sensor_v1_can_handle diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/init.lua b/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/init.lua index b7bece2efe..81d9461a38 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/init.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/arrival-sensor-v1/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Zigbee Spec Utils local zcl_messages = require "st.zigbee.zcl" @@ -41,10 +31,6 @@ local presence_utils = require "presence_utils" local CHECKIN_INTERVAL = 20 -- seconds -local function arrival_sensor_v1_can_handle(opts, driver, device, ...) - -- excluding Aqara device and tagv4 - return device:get_manufacturer() ~= "aqara" and device:get_model() ~= "tagv4" -end local function legacy_battery_handler(self, device, zb_rx) local battery_value = string.byte(zb_rx.body.zcl_body.body_bytes) @@ -144,7 +130,7 @@ local arrival_sensor_v1 = { added = added_handler, init = init_handler }, - can_handle = arrival_sensor_v1_can_handle + can_handle = require("arrival-sensor-v1.can_handle"), } return arrival_sensor_v1 diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/init.lua b/drivers/SmartThings/zigbee-presence-sensor/src/init.lua index cc1611f2ec..d797a09b62 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" @@ -204,11 +194,9 @@ local zigbee_presence_driver = { }, -- Custom handler for every Zigbee message zigbee_message_handler = all_zigbee_message_handler, - sub_drivers = { - require("aqara"), - require("arrival-sensor-v1") - }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_presence_driver, zigbee_presence_driver.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-presence-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-presence-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/presence_utils.lua b/drivers/SmartThings/zigbee-presence-sensor/src/presence_utils.lua index a68de0f95e..968d75ea54 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/presence_utils.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/presence_utils.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local presence_utils = {} diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/sub_drivers.lua b/drivers/SmartThings/zigbee-presence-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..1340826663 --- /dev/null +++ b/drivers/SmartThings/zigbee-presence-sensor/src/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("arrival-sensor-v1"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_aqara_presence_sensor_fp1.lua b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_aqara_presence_sensor_fp1.lua index 56740db3c8..b04f283c95 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_aqara_presence_sensor_fp1.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_aqara_presence_sensor_fp1.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local cluster_base = require "st.zigbee.cluster_base" local t_utils = require "integration_test.utils" @@ -58,7 +61,10 @@ test.register_coroutine_test( data_types.Uint8, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -83,7 +89,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x010C, MFG_CODE, data_types.Uint8, updates.preferences["stse.sensitivity"]) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -108,7 +117,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESET_MODE, MFG_CODE, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -133,7 +145,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0146, MFG_CODE, data_types.Uint8, updates.preferences["stse.approachDistance"]) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -148,7 +163,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", PresenceSensor.presence("present"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -163,7 +181,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", PresenceSensor.presence("not present"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -183,7 +204,10 @@ test.register_coroutine_test( test.mock_time.advance_time(movement_timer) test.socket.capability:__expect_send(mock_device:generate_test_message("main", MovementSensor.movement("noMovement"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -203,7 +227,10 @@ test.register_coroutine_test( test.mock_time.advance_time(movement_timer) test.socket.capability:__expect_send(mock_device:generate_test_message("main", MovementSensor.movement("noMovement"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -223,7 +250,10 @@ test.register_coroutine_test( test.mock_time.advance_time(movement_timer) test.socket.capability:__expect_send(mock_device:generate_test_message("main", MovementSensor.movement("noMovement"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -243,7 +273,10 @@ test.register_coroutine_test( test.mock_time.advance_time(movement_timer) test.socket.capability:__expect_send(mock_device:generate_test_message("main", MovementSensor.movement("noMovement"))) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_st_arrival_sensor_v1.lua b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_st_arrival_sensor_v1.lua index 4c2533036d..411dddda5a 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_st_arrival_sensor_v1.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_st_arrival_sensor_v1.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -107,7 +96,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(7) end - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -125,7 +117,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -134,7 +127,10 @@ test.register_coroutine_test( function () add_device() add_device_after_switch_over() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -146,7 +142,10 @@ test.register_coroutine_test( }) test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -159,7 +158,10 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.battery.battery(100))) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -172,7 +174,10 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.battery.battery(75))) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -185,7 +190,10 @@ test.register_coroutine_test( test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.battery.battery(0))) test.socket.capability:__expect_send(mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -199,7 +207,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) ) end, { - test_init = function() end + test_init = function() end, min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_zigbee_presence_sensor.lua b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_zigbee_presence_sensor.lua index 55a0d7cf51..a688b8026b 100644 --- a/drivers/SmartThings/zigbee-presence-sensor/src/test/test_zigbee_presence_sensor.lua +++ b/drivers/SmartThings/zigbee-presence-sensor/src/test/test_zigbee_presence_sensor.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -21,6 +10,7 @@ local PowerConfiguration = clusters.PowerConfiguration local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" +local presence_utils = require "presence_utils" -- Needed for building ConfigureReportingResponse msg local messages = require "st.zigbee.messages" @@ -88,7 +78,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -105,6 +96,9 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, IdentifyCluster.server.commands.Identify(mock_simple_device, 0x05) } } + }, + { + min_api_version = 14 } ) @@ -156,7 +150,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence.present())) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -189,7 +186,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -197,7 +197,10 @@ test.register_coroutine_test( function () add_device() add_device_after_switch_over() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -211,7 +214,7 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) ) end, { - test_init = function() end + test_init = function() end, min_api_version = 14 } ) @@ -240,7 +243,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(121) test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -272,7 +278,10 @@ test.register_coroutine_test( test.mock_time.advance_time(305) test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("not present")) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -302,7 +311,110 @@ test.register_coroutine_test( PowerConfiguration.ID ) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "battery_config_response_handler cancels pre-existing recurring poll timer", + function() + -- Place a live timer in the field so the nil-check branch is taken. + local pre_timer = test.timer.__create_test_time_advance_timer(60, "interval") + mock_simple_device:set_field(presence_utils.RECURRING_POLL_TIMER, pre_timer) + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + build_config_response_msg(PowerConfiguration.ID, 0x00) + }) + -- poke() emits "present" for every inbound zigbee message + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.presenceSensor.presence("present")) + ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "info_changed with changed check_interval cancels existing recurring poll timer", + function() + local pre_timer = test.timer.__create_test_time_advance_timer(60, "interval") + mock_simple_device:set_field(presence_utils.RECURRING_POLL_TIMER, pre_timer) + test.socket.device_lifecycle():__queue_receive( + mock_simple_device:generate_info_changed({ preferences = { check_interval = 100 } }) + ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +-- Build two additional mock devices (module-level) for checkInterval type variants. +-- The profile default sets checkInterval = 120 (number); we override after building. +local mock_device_str_interval = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("smartthings-arrival-sensor.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SmartThings", + model = "tagv4", + server_clusters = {0x0000, 0x0001, 0x0003} + } + } + } +) +mock_device_str_interval.preferences.checkInterval = "120" -- string → triggers elseif branch + +local mock_device_nil_interval = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("smartthings-arrival-sensor.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SmartThings", + model = "tagv4", + server_clusters = {0x0000, 0x0001, 0x0003} + } + } + } +) +mock_device_nil_interval.preferences.checkInterval = nil -- nil → triggers default-return branch + +test.register_coroutine_test( + "init with string checkInterval uses parsed value for presence timeout", + function() + test.mock_device.add_test_device(mock_device_str_interval) + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_str_interval.id, "init" }) + test.wait_for_events() + test.mock_time.advance_time(121) + test.socket.capability:__expect_send( + mock_device_str_interval:generate_test_message("main", capabilities.presenceSensor.presence("not present")) + ) + end, + { test_init = function() end, min_api_version = 14 + } +) + +test.register_coroutine_test( + "init with nil checkInterval uses default presence timeout", + function() + test.mock_device.add_test_device(mock_device_nil_interval) + test.timer.__create_and_queue_test_time_advance_timer(120, "oneshot") + test.socket.device_lifecycle:__queue_receive({ mock_device_nil_interval.id, "init" }) + test.wait_for_events() + test.mock_time.advance_time(121) + test.socket.capability:__expect_send( + mock_device_nil_interval:generate_test_message("main", capabilities.presenceSensor.presence("not present")) + ) + end, + { test_init = function() end, min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-range-extender/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-range-extender/src/frient/can_handle.lua new file mode 100644 index 0000000000..9967c0edf8 --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/frient/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and (device:get_model() == "REXZB-111") then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-range-extender/src/frient/init.lua b/drivers/SmartThings/zigbee-range-extender/src/frient/init.lua index f4a754bfeb..fef5d8470a 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/frient/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -70,9 +60,7 @@ local frient_range_extender = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" and (device:get_model() == "REXZB-111") - end + can_handle = require("frient.can_handle"), } -return frient_range_extender \ No newline at end of file +return frient_range_extender diff --git a/drivers/SmartThings/zigbee-range-extender/src/init.lua b/drivers/SmartThings/zigbee-range-extender/src/init.lua index 2523a3d45e..f06988f5cf 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/init.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/init.lua @@ -1,18 +1,9 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" +local log = require "log" local defaults = require "st.zigbee.defaults" local Basic = (require "st.zigbee.zcl.clusters").Basic local ZigbeeDriver = require "st.zigbee" @@ -32,9 +23,8 @@ local zigbee_range_driver_template = { } }, health_check = false, - sub_drivers = { - require("frient") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_range_driver_template, zigbee_range_driver_template.supported_capabilities) @@ -44,8 +34,13 @@ local zigbee_range_extender_driver = ZigbeeDriver("zigbee-range-extender", zigbe function zigbee_range_extender_driver:device_health_check() local device_list = self.device_api.get_device_list() for _, device_id in ipairs(device_list) do - local device = self:get_device_info(device_id, false) - device:send(Basic.attributes.ZCLVersion:read(device)) + local device, err = self:get_device_info(device_id, false) + if device == nil then + log.warn_with({ hub_logs = true }, + string.format("device_health_check failed to get device info for device_id %s: %s", device_id, err)) + else + device:send(Basic.attributes.ZCLVersion:read(device)) + end end end diff --git a/drivers/SmartThings/zigbee-range-extender/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-range-extender/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua b/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua new file mode 100644 index 0000000000..2f30e461ee --- /dev/null +++ b/drivers/SmartThings/zigbee-range-extender/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-range-extender/src/test/test_frient_zigbee_range_extender.lua b/drivers/SmartThings/zigbee-range-extender/src/test/test_frient_zigbee_range_extender.lua index 8f63df1705..5df7aea4a8 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/test/test_frient_zigbee_range_extender.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/test/test_frient_zigbee_range_extender.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -61,7 +50,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryVoltage:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -102,7 +94,10 @@ test.register_coroutine_test( mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -118,6 +113,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 14 } ) @@ -134,6 +132,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 14 } ) @@ -150,6 +151,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -166,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) } + }, + { + min_api_version = 14 } ) @@ -182,7 +189,48 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) +test.register_message_test( + "ZoneStatusChangeNotification - mains should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Device added lifecycle should emit mains powerSource", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_device.id, "added" } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 14 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-range-extender/src/test/test_zigbee_extend.lua b/drivers/SmartThings/zigbee-range-extender/src/test/test_zigbee_extend.lua index 39404005fa..4fddd85ec1 100644 --- a/drivers/SmartThings/zigbee-range-extender/src/test/test_zigbee_extend.lua +++ b/drivers/SmartThings/zigbee-range-extender/src/test/test_zigbee_extend.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -39,7 +28,10 @@ test.register_coroutine_test( Basic.attributes.ZCLVersion:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) -- test.register_coroutine_test( diff --git a/drivers/SmartThings/zigbee-sensor/src/init.lua b/drivers/SmartThings/zigbee-sensor/src/init.lua index 487c19a733..348d186613 100644 --- a/drivers/SmartThings/zigbee-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-sensor/src/init.lua @@ -103,8 +103,9 @@ local zigbee_generic_sensor_template = { }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_generic_sensor_template, zigbee_generic_sensor_template.supported_capabilities) local zigbee_sensor = ZigbeeDriver("zigbee-sensor", zigbee_generic_sensor_template) -zigbee_sensor:run() \ No newline at end of file +zigbee_sensor:run() diff --git a/drivers/SmartThings/zigbee-sensor/src/test/test_zigbee_sensor.lua b/drivers/SmartThings/zigbee-sensor/src/test/test_zigbee_sensor.lua index a87f7a0550..40ea0c0b4e 100644 --- a/drivers/SmartThings/zigbee-sensor/src/test/test_zigbee_sensor.lua +++ b/drivers/SmartThings/zigbee-sensor/src/test/test_zigbee_sensor.lua @@ -119,7 +119,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({mock_device_generic_sensor.id, ZoneTypeAttribute:build_test_attr_report(mock_device_generic_sensor, 0x0015)}) mock_device_generic_sensor:expect_metadata_update({profile = ZIGBEE_GENERIC_CONTACT_SENSOR_PROFILE}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -128,7 +131,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({mock_device_generic_sensor.id, ZoneTypeAttribute:build_test_attr_report(mock_device_generic_sensor, 0x000d)}) mock_device_generic_sensor:expect_metadata_update({profile = ZIGBEE_GENERIC_MOTION_SENSOR_PROFILE}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -136,7 +142,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({mock_device_motion_illuminance.id, ZoneTypeAttribute:build_test_attr_report(mock_device_motion_illuminance, 0x000d)}) mock_device_motion_illuminance:expect_metadata_update({profile = ZIGBEE_GENERIC_MOTION_ILLUMINANCE_PROFILE}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -144,7 +153,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({mock_device_generic_sensor.id, ZoneTypeAttribute:build_test_attr_report(mock_device_generic_sensor, 0x002a)}) mock_device_generic_sensor:expect_metadata_update({profile = ZIGBEE_GENERIC_WATERLEAK_SENSOR_PROFILE}) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -160,6 +172,9 @@ test.register_message_test( direction = "send", message = mock_device_contact_sensor:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -176,6 +191,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_sensor:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -192,6 +210,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_illuminance:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -208,6 +229,9 @@ test.register_message_test( direction = "send", message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -224,6 +248,9 @@ test.register_message_test( direction = "send", message = mock_device_contact_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -240,6 +267,9 @@ test.register_message_test( direction = "send", message = mock_device_contact_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -258,6 +288,9 @@ test.register_message_test( direction = "send", message = mock_device_contact_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -276,6 +309,9 @@ test.register_message_test( direction = "send", message = mock_device_contact_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -292,6 +328,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -308,6 +347,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -324,6 +366,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -340,6 +385,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -356,6 +404,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_illuminance:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -372,6 +423,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_illuminance:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -391,6 +445,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_illuminance:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 137 })) } + }, + { + min_api_version = 14 } ) @@ -407,6 +464,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_illuminance:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -423,6 +483,9 @@ test.register_message_test( direction = "send", message = mock_device_motion_illuminance:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -439,6 +502,9 @@ test.register_message_test( direction = "send", message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -455,6 +521,9 @@ test.register_message_test( direction = "send", message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -471,6 +540,9 @@ test.register_message_test( direction = "send", message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -487,6 +559,9 @@ test.register_message_test( direction = "send", message = mock_device_waterleak_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -597,7 +672,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device_contact_sensor.id, ZoneStatusAttribute:read(mock_device_contact_sensor) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -614,7 +692,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_send({ mock_device_motion_sensor.id, ZoneStatusAttribute:read(mock_device_motion_sensor) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -631,7 +712,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_send({ mock_device_motion_illuminance.id, ZoneStatusAttribute:read(mock_device_motion_illuminance) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -648,7 +732,10 @@ test.register_coroutine_test( } ) test.socket.zigbee:__expect_send({ mock_device_waterleak_sensor.id, ZoneStatusAttribute:read(mock_device_waterleak_sensor) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -719,7 +806,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device_contact_sensor.id, IASZone.attributes.ZoneStatus:read(mock_device_contact_sensor) }) mock_device_contact_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -782,7 +872,10 @@ test.register_coroutine_test( } ) mock_device_motion_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -845,7 +938,10 @@ test.register_coroutine_test( } ) mock_device_motion_illuminance:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -909,7 +1005,11 @@ test.register_coroutine_test( } ) mock_device_waterleak_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-siren/fingerprints.yml b/drivers/SmartThings/zigbee-siren/fingerprints.yml index d40b7efb9a..3361b8b717 100644 --- a/drivers/SmartThings/zigbee-siren/fingerprints.yml +++ b/drivers/SmartThings/zigbee-siren/fingerprints.yml @@ -4,18 +4,33 @@ zigbeeManufacturer : manufacturer: ClimaxTechnology model: SRAC_00.00.00.16TC deviceProfileName: switch-alarm-generic-siren-7 + - id: "frient/SIRZB-110" + deviceLabel: frient Smart Siren + manufacturer: frient A/S + model: SIRZB-110 + deviceProfileName: frient-siren-battery-source-tamper + - id: "frient/SIRZB-111" + deviceLabel: frient Smart Siren + manufacturer: frient A/S + model: SIRZB-111 + deviceProfileName: frient-siren-battery-source + - id: "frient/SIRZB-112" + deviceLabel: frient Smart Siren + manufacturer: frient A/S + model: SIRZB-112 + deviceProfileName: frient-siren-battery-source-tamper - id : Heiman/WarningDevice deviceLabel : HEIMAN Siren manufacturer : Heiman model : WarningDevice deviceProfileName : switch-alarm-generic-siren-7 - - id : frient/SIRZB-110 - deviceLabel : frient Siren - manufacturer : frient A/S - model : SIRZB-110 - deviceProfileName : switch-alarm-generic-siren-7 - id: "Sercomm Corp./SZ-SRN12N" deviceLabel: SmartThings Siren manufacturer: Sercomm Corp. model: SZ-SRN12N deviceProfileName: basic-alarm + - id: "MultIR/MIR-SR100" + deviceLabel: MultiIR Siren MIR-SR100 + manufacturer: MultIR + model: MIR-SR100 + deviceProfileName: switch-alarm-tamper-warningduration-volume-no-fw-update diff --git a/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source-tamper.yml b/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source-tamper.yml new file mode 100644 index 0000000000..6c7e991e45 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source-tamper.yml @@ -0,0 +1,53 @@ +name: frient-siren-battery-source-tamper +components: + - id: main + capabilities: + - id: alarm + version: 1 + - id: tone + version: 1 + - id: battery + version: 1 + - id: powerSource + version: 1 + - id: tamperAlert + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren + - id: SirenVolume + capabilities: + - id: mode + version: 1 + - id: SirenVoice + capabilities: + - id: mode + version: 1 + - id: SquawkVolume + capabilities: + - id: mode + version: 1 + - id: SquawkVoice + capabilities: + - id: mode + version: 1 + - id: WarningDuration + capabilities: + - id: mode + version: 1 +preferences: + - title: "Max alarm duration (s)" + name: maxWarningDuration + description: "After how many seconds should the alarm turn off" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 +metadata: + mnmn: SmartThings + vid: SmartThings-smartthings-frient_Siren_Tamper \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source.yml b/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source.yml new file mode 100644 index 0000000000..5248db2b54 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/profiles/frient-siren-battery-source.yml @@ -0,0 +1,51 @@ +name: frient-siren-battery-source +components: + - id: main + capabilities: + - id: alarm + version: 1 + - id: tone + version: 1 + - id: battery + version: 1 + - id: powerSource + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren + - id: SirenVolume + capabilities: + - id: mode + version: 1 + - id: SirenVoice + capabilities: + - id: mode + version: 1 + - id: SquawkVolume + capabilities: + - id: mode + version: 1 + - id: SquawkVoice + capabilities: + - id: mode + version: 1 + - id: WarningDuration + capabilities: + - id: mode + version: 1 +preferences: + - title: "Max alarm duration (s)" + name: maxWarningDuration + description: "After how many seconds should the alarm turn off" + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65534 + default: 240 +metadata: + mnmn: SmartThings + vid: SmartThings-smartthings-frient_Siren \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-siren/profiles/switch-alarm-tamper-warningduration-volume-no-fw-update.yml b/drivers/SmartThings/zigbee-siren/profiles/switch-alarm-tamper-warningduration-volume-no-fw-update.yml new file mode 100644 index 0000000000..5192f236dc --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/profiles/switch-alarm-tamper-warningduration-volume-no-fw-update.yml @@ -0,0 +1,36 @@ +name: switch-alarm-tamper-warningduration-volume-no-fw-update +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: alarm + version: 1 + - id: tamperAlert + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren +preferences: + - title: "警报时长/秒(warning duration/sec)" + name: warningDuration + description: "警报持续时间 单位:秒(warning duration unit:seconds)" + required: false + preferenceType: integer + definition: + minimum: 30 + maximum: 1800 + default: 1800 + - title: "警报音量(siren volume)" + name: sirenVolume + description: "警报音量大小(siren volume)" + required: false + preferenceType: enumeration + definition: + options: + 0: "低(low)" + 1: "中(medium)" + 2: "高(high)" + 3: "最大(max)" + default: 3 diff --git a/drivers/SmartThings/zigbee-siren/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-siren/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-siren/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-siren/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..ef955eecc6 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-SR100" } +} diff --git a/drivers/SmartThings/zigbee-siren/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-siren/src/MultiIR/init.lua new file mode 100644 index 0000000000..c9934936af --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/MultiIR/init.lua @@ -0,0 +1,119 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local data_types = require "st.zigbee.data_types" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local IASWD = zcl_clusters.IASWD +local IASZone = zcl_clusters.IASZone +local IaswdLevel = IASWD.types.IaswdLevel +local SirenConfiguration = IASWD.types.SirenConfiguration +local WarningMode = IASWD.types.WarningMode +local Strobe = IASWD.types.Strobe +local capabilities = require "st.capabilities" +local ALARM_COMMAND = "alarmCommand" +local DEFAULT_MAX_WARNING_DURATION = 1800 +local ALARM_STROBE_DUTY_CYCLE = 40 + +local alarm_command = { + OFF = 0, + SIREN = 1, + STROBE = 2, + BOTH = 3 +} + +local function device_added (driver, device) + device:emit_event(capabilities.switch.switch.off()) + device:emit_event(capabilities.alarm.alarm.off()) + if(device:supports_capability(capabilities.tamperAlert)) then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end + device:send(IASWD.attributes.MaxDuration:read(device)) +end + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + if device:supports_capability(capabilities.tamperAlert) then + device:emit_event(zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function send_siren_command(device, warning_mode, warning_siren_level, warning_duration, strobe_active, strobe_level) + local siren_configuration + + siren_configuration = SirenConfiguration(0x00) + siren_configuration:set_warning_mode(warning_mode) + siren_configuration:set_siren_level(warning_siren_level) + siren_configuration:set_strobe(strobe_active) + + device:send( + IASWD.server.commands.StartWarning( + device, + siren_configuration, + data_types.Uint16(warning_duration), + data_types.Uint8(ALARM_STROBE_DUTY_CYCLE), + data_types.Enum8(strobe_level) + ) + ) +end + +local function siren_switch_off_handler(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.OFF, {persist = true}) + send_siren_command(device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) +end + +local function siren_alarm_siren_handler(alarm_cmd, WarningMode, Strobe, strobe_level) + return function(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_cmd, {persist = true}) + + local sirenVolume_msg = tonumber(device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + + send_siren_command(device, WarningMode, sirenVolume_msg, warning_duration, Strobe, strobe_level) + + device.thread:call_with_delay(warning_duration, function() -- Send command to switch from siren to off in the app when the siren is done + if(device:get_field(ALARM_COMMAND) ~= alarm_command.OFF) then + siren_switch_off_handler(driver, device, alarm_cmd) + end + end) + end +end + +local MultiIR_siren_driver = { + NAME = "MultiIR Zigbee siren", + lifecycle_handlers = { + added = device_added, + }, + capability_handlers = { + [capabilities.alarm.ID] = { + [capabilities.alarm.commands.off.NAME] = siren_switch_off_handler, + [capabilities.alarm.commands.siren.NAME] = siren_alarm_siren_handler(alarm_command.SIREN, WarningMode.BURGLAR, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL), + [capabilities.alarm.commands.both.NAME] = siren_alarm_siren_handler(alarm_command.BOTH, WarningMode.BURGLAR, Strobe.USE_STROBE , IaswdLevel.VERY_HIGH_LEVEL), + [capabilities.alarm.commands.strobe.NAME] = siren_alarm_siren_handler(alarm_command.STROBE, WarningMode.STOP, Strobe.USE_STROBE, IaswdLevel.VERY_HIGH_LEVEL) + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = siren_alarm_siren_handler(alarm_command.BOTH, WarningMode.BURGLAR, Strobe.USE_STROBE , IaswdLevel.VERY_HIGH_LEVEL), + [capabilities.switch.commands.off.NAME] = siren_switch_off_handler + } + }, + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = generate_event_from_zone_status + } + } + }, + can_handle = require("MultiIR.can_handle"), +} + +return MultiIR_siren_driver diff --git a/drivers/SmartThings/zigbee-siren/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-siren/src/frient/can_handle.lua new file mode 100644 index 0000000000..ed6acf5b42 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/frient/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and + (device:get_model() == "SIRZB-110" or + device:get_model() == "SIRZB-111" or + device:get_model() == "SIRZB-112") + then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-siren/src/frient/init.lua b/drivers/SmartThings/zigbee-siren/src/frient/init.lua index 085b8ecd79..d408579696 100644 --- a/drivers/SmartThings/zigbee-siren/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-siren/src/frient/init.lua @@ -1,97 +1,416 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" +local battery_defaults = require "st.zigbee.defaults.battery_defaults" --ZCL local zcl_clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local Basic = zcl_clusters.Basic local IASWD = zcl_clusters.IASWD -local SirenConfiguration = IASWD.types.SirenConfiguration +local IASZone = zcl_clusters.IASZone local IaswdLevel = IASWD.types.IaswdLevel +local SirenConfiguration = IASWD.types.SirenConfiguration +local SquawkConfiguration = IASWD.types.SquawkConfiguration +local SquawkMode = IASWD.types.SquawkMode +local WarningMode = IASWD.types.WarningMode +local PowerConfiguration = zcl_clusters.PowerConfiguration --capability local capabilities = require "st.capabilities" local alarm = capabilities.alarm -local switch = capabilities.switch local ALARM_COMMAND = "alarmCommand" -local ALARM_LAST_DURATION = "lastDuration" -local ALARM_MAX_DURATION = "maxDuration" +local ALARM_DURATION = "warningDuration" +local SIREN_FIXED_ENDIAN_SW_VERSION = "010903" -local ALARM_DEFAULT_MAX_DURATION = 0x00B4 -local ALARM_STROBE_DUTY_CYCLE = 00 +local DEFAULT_MAX_WARNING_DURATION = 0x00F0 +local PRIMARY_SW_VERSION = "primary_sw_version" +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 +local DEVELCO_MANUFACTURER_CODE = 0x1015 +local IASZONE_ENDPOINT = 0x2B local alarm_command = { OFF = 0, SIREN = 1, - STROBE = 2, - BOTH = 3 } -local send_siren_command = function(device) - local max_duration = device:get_field(ALARM_MAX_DURATION) - local warning_duration = max_duration and max_duration or ALARM_DEFAULT_MAX_DURATION - local duty_cycle = ALARM_STROBE_DUTY_CYCLE +local IASZone_configuration = { + { + cluster = IASZone.ID, + attribute = IASZone.attributes.ZoneStatus.ID, + minimum_interval = 0, + maximum_interval = 6*60*60, + data_type = IASZone.attributes.ZoneStatus.base_type, + reportable_change = 1 + } +} + +local SQUAWK_VOICE_MAP = { + ["Armed"] = 0, + ["Disarmed"] = 1 +} + +local VOLUME_MAP = { + ["Low"] = 0, + ["Medium"] = 1, + ["High"] = 2, + ["Very High"] = 3 +} +local SIREN_VOICE_MAP = { + ["Burglar"] = 1, + ["Fire"] = 2, + ["Emergency"] = 3, + ["Panic"] = 4, + ["Panic Fire"] = 5, + ["Panic Emergency"] = 6 +} - device:set_field(ALARM_LAST_DURATION, warning_duration, {persist = true}) +local WARNING_DURATION_MAP = { + ["5 seconds"] = 5, + ["10 seconds"] = 10, + ["15 seconds"] = 15, + ["20 seconds"] = 20, + ["25 seconds"] = 25, + ["30 seconds"] = 30, + ["40 seconds"] = 40, + ["50 seconds"] = 50, + ["1 minute"] = 60, + ["2 minutes"] = 120, + ["3 minutes"] = 180, + ["4 minutes"] = 240, + ["5 minutes"] = 300, + ["10 minutes"] = 600 +} - local siren_configuration = SirenConfiguration(0xC1) +local MODEL_DEVICE_PROFILE_MAP = { + ["SIRZB-110"] = "frient-siren-battery-source-tamper", + ["SIRZB-111"] = "frient-siren-battery-source" +} - device:send( - IASWD.server.commands.StartWarning( - device, - siren_configuration, - data_types.Uint16(warning_duration), - data_types.Uint8(duty_cycle), - data_types.Enum8(IaswdLevel.LOW_LEVEL) +local BATTERY_CONFIG_APPLIED_KEY = "_frient_battery_config_applied" + +local function get_current_max_warning_duration(device) + return device.preferences.maxWarningDuration == nil and DEFAULT_MAX_WARNING_DURATION or device.preferences.maxWarningDuration +end + +local function get_warning_duration(device) + -- User may select one of predefine modes on the UI or input duration in preferences + -- every time ALARM_DURATION is updated + local selected_duration = device:get_field(ALARM_DURATION) + local current_max_warning_duration = get_current_max_warning_duration(device) + + local warning_duration + if selected_duration == nil or selected_duration > current_max_warning_duration then + warning_duration = current_max_warning_duration + else + warning_duration = selected_duration + end + return warning_duration +end + +local function configure_battery_handling_based_on_fw(driver, device) + local sw_version = device:get_field(PRIMARY_SW_VERSION) + local applied_state = device:get_field(BATTERY_CONFIG_APPLIED_KEY) + + if sw_version and sw_version < SIREN_FIXED_ENDIAN_SW_VERSION then + if applied_state ~= "voltage" then + -- Old firmware - does not support BatteryPercentageRemaining attribute, use battery defaults (voltage-based) + battery_defaults.build_linear_voltage_init(3.3, 4.1)(driver, device) + device:set_field(BATTERY_CONFIG_APPLIED_KEY, "voltage", { persist = true }) + return true + end + else + if applied_state ~= "percentage" then + -- New firmware - supports BatteryPercentageRemaining, remove voltage monitoring + device:remove_configured_attribute(PowerConfiguration.ID, PowerConfiguration.attributes.BatteryVoltage.ID) + device:remove_monitored_attribute(PowerConfiguration.ID, PowerConfiguration.attributes.BatteryVoltage.ID) + device:set_field(BATTERY_CONFIG_APPLIED_KEY, "percentage", { persist = true }) + return true + end + end + + return false +end + +local function device_init(driver, device) + for _, attribute in ipairs(IASZone_configuration) do + device:add_configured_attribute(attribute) + device:add_monitored_attribute(attribute) + end +end + +local function device_added (driver, device) + for comp_name, comp in pairs(device.profile.components) do + if comp_name ~= "main" then + if comp_name == "SirenVoice" then + device:emit_component_event(comp, capabilities.mode.supportedModes({"Burglar", "Fire", "Emergency", "Panic","Panic Fire","Panic Emergency" }, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.supportedArguments({"Burglar", "Fire", "Emergency", "Panic","Panic Fire","Panic Emergency" }, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.mode("Burglar")) + elseif comp_name == "SquawkVoice" then + device:emit_component_event(comp, capabilities.mode.supportedModes({"Armed", "Disarmed"}, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.supportedArguments({"Armed", "Disarmed"}, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.mode("Armed")) + elseif comp_name == "WarningDuration" then + device:emit_component_event(comp, capabilities.mode.supportedModes({ + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.supportedArguments({ + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.mode("4 minutes")) + else + device:emit_component_event(comp, capabilities.mode.supportedModes({"Low", "Medium", "High", "Very High"}, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.supportedArguments({"Low", "Medium", "High", "Very High"}, {visibility = {displayed = false}})) + device:emit_component_event(comp, capabilities.mode.mode("Very High")) + end + end + end + + device:emit_event(capabilities.alarm.alarm.off()) + + if(device:supports_capability(capabilities.tamperAlert)) then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function do_refresh(driver, device) + device:refresh() + device:send(IASZone.attributes.ZoneStatus:read(device):to_endpoint(IASZONE_ENDPOINT)) + + -- Check if we have the software version + local sw_version = device:get_field(PRIMARY_SW_VERSION) + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + end +end + +local function do_configure(driver, device) + local maxWarningDuration = get_current_max_warning_duration(device) + device:set_field(ALARM_DURATION, maxWarningDuration , { persist = true}) + device:send(IASWD.attributes.MaxDuration:write(device, maxWarningDuration):to_endpoint(0x2B)) + + -- Check if we have the software version + local sw_version = device:get_field(PRIMARY_SW_VERSION) + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + else + configure_battery_handling_based_on_fw(driver, device) + end + + -- handle frient sirens that were already connected and using old device profile + if (not device:supports_capability_by_id(capabilities.mode.ID)) then + device:try_update_metadata({profile = MODEL_DEVICE_PROFILE_MAP[device:get_model()]}) + end + + device.thread:call_with_delay(5, function() + do_refresh(driver, device) + end) + device:configure() +end + +local function primary_sw_version_attr_handler(driver, device, value, zb_rx) + local primary_sw_version = value.value:gsub('.', function (c) return string.format('%02x', string.byte(c)) end) + device:set_field(PRIMARY_SW_VERSION, primary_sw_version, {persist = true}) + local config_changed = configure_battery_handling_based_on_fw(driver, device) + if config_changed then + device.thread:call_with_delay(1, function() + device:configure() + end) + end + device.thread:call_with_delay(config_changed and 2 or 1, function() + do_refresh(driver, device) + end) +end + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + if device:supports_capability(capabilities.tamperAlert) then + device:emit_event_for_endpoint( + zb_rx.address_header.src_endpoint.value, + zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear() ) + end + device:emit_event_for_endpoint( + zb_rx.address_header.src_endpoint.value, + zone_status:is_ac_mains_fault_set() and capabilities.powerSource.powerSource.battery() or capabilities.powerSource.powerSource.mains() + ) +end + +local function ias_zone_status_attr_handler(driver, device, zone_status, zb_rx) + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function send_siren_command(device, warning_mode, warning_siren_level) + -- Check if we have the software version first + local sw_version = device:get_field(PRIMARY_SW_VERSION) + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + end + + local warning_duration = get_warning_duration(device) + + local siren_configuration + + if (sw_version and sw_version < SIREN_FIXED_ENDIAN_SW_VERSION) then + -- Old frient firmware, the endian format is reversed + local siren_config_value = (warning_siren_level << 6) | warning_mode + siren_configuration = SirenConfiguration(siren_config_value) + else + siren_configuration = SirenConfiguration(0x00) + siren_configuration:set_warning_mode(warning_mode) + siren_configuration:set_siren_level(warning_siren_level) + end + + device:send( + IASWD.server.commands.StartWarning( + device, + siren_configuration, + data_types.Uint16(warning_duration), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) ) end -local siren_switch_both_handler = function(driver, device, command) - device:set_field(ALARM_COMMAND, alarm_command.BOTH, {persist = true}) - send_siren_command(device) +local function siren_switch_off_handler(driver, device, command) + device:set_field(ALARM_COMMAND, alarm_command.OFF, {persist = true}) + send_siren_command(device, WarningMode.STOP, IaswdLevel.LOW_LEVEL) end -local siren_alarm_siren_handler = function(driver, device, command) +local function siren_alarm_siren_handler(driver, device, command) device:set_field(ALARM_COMMAND, alarm_command.SIREN, {persist = true}) - send_siren_command(device) + + -- delay is needed to allow st automations get updated fields when mode, volume, voice is set sequentially + device.thread:call_with_delay(1, function() + local sirenVoice_msg = device:get_latest_state("SirenVoice", capabilities.mode.ID, capabilities.mode.mode.NAME) + local sirenVolume_msg = device:get_latest_state("SirenVolume", capabilities.mode.ID, capabilities.mode.mode.NAME) + send_siren_command(device,sirenVoice_msg == nil and WarningMode.BURGLAR or SIREN_VOICE_MAP[sirenVoice_msg] , sirenVolume_msg == nil and IaswdLevel.VERY_HIGH_LEVEL or VOLUME_MAP[sirenVolume_msg]) + end) + + local warningDurationDelay = get_warning_duration(device) + + device.thread:call_with_delay(warningDurationDelay, function() -- Send command to switch from siren to off in the app when the siren is done + if(device:get_field(ALARM_COMMAND) == alarm_command.SIREN) then + siren_switch_off_handler(driver, device, command) + end + end) +end + +local function send_squawk_command(device, squawk_mode, squawk_siren_level) + -- Check if we have the software version first + local sw_version = device:get_field(PRIMARY_SW_VERSION) + + if ((sw_version == nil) or (sw_version == "")) then + device:send(cluster_base.read_manufacturer_specific_attribute(device, Basic.ID, DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, DEVELCO_MANUFACTURER_CODE)) + end + + local squawk_configuration + + if (sw_version and sw_version < SIREN_FIXED_ENDIAN_SW_VERSION) then + -- Old frient firmware, the endian format is reversed + local squawk_config_value = (squawk_siren_level << 6) | squawk_mode + squawk_configuration = SquawkConfiguration(squawk_config_value) + else + squawk_configuration = SquawkConfiguration(0x00) + squawk_configuration:set_squawk_mode(squawk_mode) + squawk_configuration:set_squawk_level(squawk_siren_level) + end + + device:send( + IASWD.server.commands.Squawk( + device, + squawk_configuration + ) + ) +end + +local function siren_tone_beep_handler(driver, device, command) + device.thread:call_with_delay(1, function () + local squawkVolume_msg = device:get_latest_state("SquawkVolume", capabilities.mode.ID, capabilities.mode.mode.NAME) + local squawkVoice_msg = device:get_latest_state("SquawkVoice", capabilities.mode.ID, capabilities.mode.mode.NAME) + + send_squawk_command(device, SQUAWK_VOICE_MAP[squawkVoice_msg] or SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED,VOLUME_MAP[squawkVolume_msg] or IaswdLevel.VERY_HIGH_LEVEL) + end ) end -local siren_alarm_strobe_handler = function(driver, device, command) - device:set_field(ALARM_COMMAND, alarm_command.STROBE, {persist = true}) - send_siren_command(device) +local function info_changed(driver, device, event, args) + for name, info in pairs(device.preferences) do + if (device.preferences[name] ~= nil and args.old_st_store.preferences[name] ~= device.preferences[name]) then + local input = device.preferences[name] + if (name == "maxWarningDuration") then + device:send(IASWD.attributes.MaxDuration:write(device, tonumber(input))) + end + end + end end -local siren_switch_on_handler = function(driver, device, command) - siren_switch_both_handler(driver, device, command) +local function siren_mode_handler(driver, device, command) + local mode_set = command.args.mode + local component = command.component + local compObj = device.profile.components[component] + + if compObj then + if component == "WarningDuration" then + local warning_duration = WARNING_DURATION_MAP[mode_set] or DEFAULT_MAX_WARNING_DURATION + device:set_field(ALARM_DURATION, warning_duration, {persist = true}) + end + end + + device.thread:call_with_delay(2,function() + device:emit_component_event( + compObj, + capabilities.mode.mode(mode_set)) + end) end local frient_siren_driver = { NAME = "frient A/S", + lifecycle_handlers = { + added = device_added, + init = device_init, + doConfigure = do_configure, + infoChanged = info_changed, + }, capability_handlers = { [alarm.ID] = { - [alarm.commands.both.NAME] = siren_switch_both_handler, + [alarm.commands.off.NAME] = siren_switch_off_handler, [alarm.commands.siren.NAME] = siren_alarm_siren_handler, - [alarm.commands.strobe.NAME] = siren_alarm_strobe_handler + [alarm.commands.both.NAME] = siren_alarm_siren_handler + }, + [capabilities.tone.ID] = { + [capabilities.tone.commands.beep.NAME] = siren_tone_beep_handler }, - [switch.ID] = { - [switch.commands.on.NAME] = siren_switch_on_handler, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = siren_mode_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" - end + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = ias_zone_status_attr_handler + }, + [Basic.ID] = { + [DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR] = primary_sw_version_attr_handler + } + } + }, + can_handle = require("frient.can_handle"), } -return frient_siren_driver +return frient_siren_driver \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-siren/src/init.lua b/drivers/SmartThings/zigbee-siren/src/init.lua index 0f08b067fb..8e4812c3bd 100644 --- a/drivers/SmartThings/zigbee-siren/src/init.lua +++ b/drivers/SmartThings/zigbee-siren/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" @@ -32,7 +22,9 @@ local IaswdLevel = IASWD.types.IaswdLevel local capabilities = require "st.capabilities" local alarm = capabilities.alarm local switch = capabilities.switch - +local mode = capabilities.mode +local battery = capabilities.battery +local refresh = capabilities.refresh -- Constants local ALARM_COMMAND = "alarmCommand" local ALARM_LAST_DURATION = "lastDuration" @@ -81,13 +73,13 @@ local send_siren_command = function(device, warning_mode, warning_siren_level, s siren_configuration:set_siren_level(warning_siren_level) device:send( - IASWD.server.commands.StartWarning( - device, - siren_configuration, - data_types.Uint16(warning_duration), - data_types.Uint8(duty_cycle), - data_types.Enum8(strobe_level) - ) + IASWD.server.commands.StartWarning( + device, + siren_configuration, + data_types.Uint16(warning_duration), + data_types.Uint8(duty_cycle), + data_types.Enum8(strobe_level) + ) ) end @@ -157,7 +149,10 @@ end local zigbee_siren_driver_template = { supported_capabilities = { alarm, - switch + switch, + mode, + battery, + refresh }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, zigbee_handlers = { @@ -189,7 +184,7 @@ local zigbee_siren_driver_template = { added = device_added, doConfigure = do_configure }, - sub_drivers = { require("ozom"), require("frient") }, + sub_drivers = require("sub_drivers"), cluster_configurations = { [alarm.ID] = { { @@ -202,6 +197,7 @@ local zigbee_siren_driver_template = { } }, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_siren_driver_template, zigbee_siren_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-siren/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-siren/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-siren/src/ozom/can_handle.lua b/drivers/SmartThings/zigbee-siren/src/ozom/can_handle.lua new file mode 100644 index 0000000000..bec6069a7f --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/ozom/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function ozom_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "ClimaxTechnology" then + return true, require("ozom") + end + return false +end + +return ozom_can_handle diff --git a/drivers/SmartThings/zigbee-siren/src/ozom/init.lua b/drivers/SmartThings/zigbee-siren/src/ozom/init.lua index e4cecc28ab..c17a1c4fb7 100644 --- a/drivers/SmartThings/zigbee-siren/src/ozom/init.lua +++ b/drivers/SmartThings/zigbee-siren/src/ozom/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" --ZCL @@ -75,9 +65,7 @@ local ozom_siren_driver = { [switch.commands.on.NAME] = siren_switch_on_handler } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "ClimaxTechnology" - end + can_handle = require("ozom.can_handle"), } return ozom_siren_driver diff --git a/drivers/SmartThings/zigbee-siren/src/sub_drivers.lua b/drivers/SmartThings/zigbee-siren/src/sub_drivers.lua new file mode 100644 index 0000000000..5f5953b04e --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("frient"), + lazy_load_if_possible("ozom"), + lazy_load_if_possible("MultiIR"), +} diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren.lua b/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren.lua index 098b2ab1bd..3ecee4b16d 100644 --- a/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren.lua +++ b/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2022 SmartThings, Inc. -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -15,110 +15,1176 @@ -- Mock out globals local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local OnOff = clusters.OnOff +local Scenes = clusters.Scenes +local Basic = clusters.Basic +local Identify = clusters.Identify +local PowerConfiguration = clusters.PowerConfiguration +local Groups = clusters.Groups +local IASZone = clusters.IASZone local IASWD = clusters.IASWD +local IaswdLevel = IASWD.types.IaswdLevel +local WarningMode = IASWD.types.WarningMode +local SquawkMode = IASWD.types.SquawkMode +local SirenConfiguration = IASWD.types.SirenConfiguration +local SquawkConfiguration = IASWD.types.SquawkConfiguration +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus + +local PRIMARY_SW_VERSION = "primary_sw_version" +local SIREN_ENDIAN = "siren_endian" +local ALARM_DURATION = "warningDuration" +local ALARM_DEFAULT_MAX_DURATION = 0x00F0 +local ALARM_DURATION_TEST_VALUE = 5 +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 +local DEVELCO_MANUFACTURER_CODE = 0x1015 + +local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local data_types = require "st.zigbee.data_types" -local SirenConfiguration = require "st.zigbee.generated.zcl_clusters.IASWD.types.SirenConfiguration" local t_utils = require "integration_test.utils" + local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("switch-alarm.yml"), - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "frient A/S", - model = "SIRZB-110", - server_clusters = {0x0502} + { + profile = t_utils.get_profile_definition("frient-siren-battery-source.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "SIRZB-111", + server_clusters = { Scenes.ID, OnOff.ID} + }, + [0x2B] = { + id = 0x2B, + server_clusters = { Basic.ID, Identify.ID, PowerConfiguration.ID, Groups.ID, IASZone.ID, IASWD.ID } + } + } } - } - } ) zigbee_test_utils.prepare_zigbee_env_info() local function test_init() - test.mock_device.add_test_device(mock_device)end + test.mock_device.add_test_device(mock_device) +end test.set_test_init_function(test_init) +local function set_new_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "010903", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function set_older_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "010901", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function set_no_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function get_siren_commands_new_fw( warningMode, sirenLevel, duration ) + local expectedSirenONConfiguration = SirenConfiguration(0x00) + expectedSirenONConfiguration:set_warning_mode(warningMode) --WarningMode.BURGLAR + expectedSirenONConfiguration:set_siren_level(sirenLevel) --IaswdLevel.VERY_HIGH_LEVEL + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenONConfiguration, + data_types.Uint16(duration), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_siren_commands_old_fw(warningMode, sirenLevel) + local expectedSirenONConfiguration + local siren_config_value = (sirenLevel << 6) | warningMode + expectedSirenONConfiguration = SirenConfiguration(siren_config_value) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenONConfiguration, + data_types.Uint16(ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_siren_OFF_commands(duration) + local expectedSirenOFFConfiguration = SirenConfiguration(0x00) + expectedSirenOFFConfiguration:set_warning_mode(WarningMode.STOP) + expectedSirenOFFConfiguration:set_siren_level(IaswdLevel.LOW_LEVEL) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenOFFConfiguration, + data_types.Uint16(duration and duration or ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_squawk_command_new_fw(squawk_mode, squawk_siren_level) + local expected_squawk_configuration = SquawkConfiguration(0x00) + expected_squawk_configuration:set_squawk_mode(squawk_mode) + expected_squawk_configuration:set_squawk_level(squawk_siren_level) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.Squawk( + mock_device, + expected_squawk_configuration + ) + }) +end + +local function get_squawk_command_older_fw(squawk_mode, squawk_siren_level) + local expected_squawk_configuration + local squawk_config_value = (squawk_siren_level << 6) | squawk_mode + expected_squawk_configuration = SquawkConfiguration(squawk_config_value) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.Squawk( + mock_device, + expected_squawk_configuration + ) + }) +end + +test.register_coroutine_test( + "lifecycles - init and doConfigure test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write( + mock_device, + ALARM_DEFAULT_MAX_DURATION + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting( + mock_device, + 30, + 21600, + 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 0, + 21600, + 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.supportedModes({ "Burglar", "Fire", "Emergency", "Panic", "Panic Fire", "Panic Emergency" }, { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.supportedArguments({ "Burglar", "Fire", "Emergency", "Panic", "Panic Fire", "Panic Emergency" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.mode("Burglar") + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.supportedModes({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.supportedArguments({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.mode({value = "Very High"}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.supportedModes({ "Armed", "Disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.supportedArguments({ "Armed", "Disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.mode("Armed") + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.supportedModes({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.supportedArguments({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.mode("Very High") + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.supportedModes( + { + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.supportedArguments( + { + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.mode("4 minutes") + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.alarm.alarm.off() + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on the siren (test with default settings)", + function() + set_new_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "010903", "PRIMARY_SW_VERSION should be greater than or equal to '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.BURGLAR,IaswdLevel.VERY_HIGH_LEVEL,ALARM_DURATION_TEST_VALUE) + test.mock_time.advance_time(ALARM_DURATION_TEST_VALUE+1) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on the siren (test with default settings)", + function() + set_older_firmware_and_defaults() + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "010903", "PRIMARY_SW_VERSION should be lower than '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_old_fw(WarningMode.BURGLAR,IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(ALARM_DURATION_TEST_VALUE) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Alarm OFF should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"5 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("5 seconds")) + ) + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL,ALARM_DURATION_TEST_VALUE) + test.mock_time.advance_time(5) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled - in case of NO FW version was reported before", + function() + set_no_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"5 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("5 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL,ALARM_DURATION_TEST_VALUE) + + test.mock_time.advance_time(5) + -- stop the siren + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the OFF command + get_siren_OFF_commands() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled - when maxWarningDuration is shorted then selected mode", + function() + local expectedWarningDuration = 7 + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(expectedWarningDuration, "oneshot") + + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed( + { + preferences = { + maxWarningDuration = expectedWarningDuration + } + } + )) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write( + mock_device, + expectedWarningDuration + ) + }) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"50 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("50 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL, expectedWarningDuration) + + test.mock_time.advance_time(expectedWarningDuration) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands(expectedWarningDuration) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on squawk (test with default settings)", + function() + + set_new_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "010903", "PRIMARY_SW_VERSION should be greater than or equal to '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_new_fw( SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED, IaswdLevel.VERY_HIGH_LEVEL ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on squawk (test with default settings)", + function() + set_older_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "010903", "PRIMARY_SW_VERSION should be lower than '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_older_fw( SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED, IaswdLevel.VERY_HIGH_LEVEL ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_new_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled - in case of OLD FW version was reported before", + function() + set_older_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_older_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled - in case of NO FW version was reported before", + function() + set_no_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the command with given configuration + get_squawk_command_new_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Refresh should be handled - new FW", + function() + set_new_firmware_and_defaults() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Refresh should be handled - FW not known", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ) + } + ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + test.register_message_test( - "Capability(switch) command(on) on should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "switch", command = "on", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, IASWD.server.commands.StartWarning(mock_device, - SirenConfiguration(0xC1), - data_types.Uint16(0x00B4), - data_types.Uint8(00), - data_types.Enum8(00)) } - } - } + "Power source / mains should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 14 + } ) test.register_message_test( - "Capability(alarm) command(both) on should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "alarm", component = "main", command = "both", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, IASWD.server.commands.StartWarning(mock_device, - SirenConfiguration(0xC1), - data_types.Uint16(0x00B4), - data_types.Uint8(00), - data_types.Enum8(00)) } - } - } + "Power source / battery and tamper clear should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0081) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + } + }, + { + min_api_version = 14 + } ) test.register_message_test( - "Capability(alarm) command(siren) on should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "alarm", component = "main", command = "siren", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, IASWD.server.commands.StartWarning(mock_device, - SirenConfiguration(0xC1), - data_types.Uint16(0x00B4), - data_types.Uint8(00), - data_types.Enum8(00)) } - } - } + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0xC8) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 14 + } ) test.register_message_test( - "Capability(alarm) command(strobe) on should be handled", - { - { - channel = "capability", - direction = "receive", - message = { mock_device.id, { capability = "alarm", component = "main", command = "strobe", args = { } } } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, IASWD.server.commands.StartWarning(mock_device, - SirenConfiguration(0xC1), - data_types.Uint16(0x00B4), - data_types.Uint8(00), - data_types.Enum8(00)) } - } - } + "Medium battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0x64) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 14 + } +) + +local function build_sw_version_attr_report(device, fw_bytes) + local zcl_messages_mod = require "st.zigbee.zcl" + local messages_mod = require "st.zigbee.messages" + local zb_const_mod = require "st.zigbee.constants" + local report_attr = require "st.zigbee.zcl.global_commands.report_attribute" + local zcl_cmds_mod = require "st.zigbee.zcl.global_commands" + + local attr_record = report_attr.ReportAttributeAttributeRecord( + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + data_types.CharString.ID, + fw_bytes + ) + local report_body = report_attr.ReportAttribute({ attr_record }) + local zclh = zcl_messages_mod.ZclHeader({ + cmd = data_types.ZCLCommandId(zcl_cmds_mod.REPORT_ATTRIBUTE_ID) + }) + local addrh = messages_mod.AddressHeader( + device:get_short_address(), + device:get_endpoint(Basic.ID), + zb_const_mod.HUB.ADDR, + zb_const_mod.HUB.ENDPOINT, + zb_const_mod.HA_PROFILE_ID, + Basic.ID + ) + local message_body = zcl_messages_mod.ZclMessageBody({ zcl_header = zclh, zcl_body = report_body }) + return messages_mod.ZigbeeMessageRx({ address_header = addrh, body = message_body }) +end + +test.register_coroutine_test( + "SW version attr handler with new firmware should configure battery percentage", + function() + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + mock_device:set_field("_frient_battery_config_applied", nil, {persist = true}) + + -- Binary "\x01\x09\x03" -> hex "010903" (new firmware >= SIREN_FIXED_ENDIAN_SW_VERSION) + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_sw_version_attr_report(mock_device, "\x01\x09\x03") + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SW version attr handler with old firmware should configure battery voltage", + function() + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + mock_device:set_field("_frient_battery_config_applied", nil, {persist = true}) + + -- Binary "\x01\x09\x01" -> hex "010901" (old firmware < SIREN_FIXED_ENDIAN_SW_VERSION) + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_sw_version_attr_report(mock_device, "\x01\x09\x01") + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "doConfigure with existing sw_version should call configure_battery_handling", + function() + mock_device:set_field(PRIMARY_SW_VERSION, "010903", {persist = true}) + mock_device:set_field("_frient_battery_config_applied", nil, {persist = true}) + + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write(mock_device, ALARM_DEFAULT_MAX_DURATION) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting( + mock_device, 30, 21600, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 0, 21600, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write(mock_device, zigbee_test_utils.mock_hub_eui) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse(mock_device, IasEnrollResponseCode.SUCCESS, 0x00) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren_tamper.lua b/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren_tamper.lua new file mode 100644 index 0000000000..cfa4e8b8a5 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/test/test_frient_siren_tamper.lua @@ -0,0 +1,1142 @@ +-- Copyright 2025 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. + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local IasEnrollResponseCode = require "st.zigbee.generated.zcl_clusters.IASZone.types.EnrollResponseCode" +local OnOff = clusters.OnOff +local Scenes = clusters.Scenes +local Basic = clusters.Basic +local Identify = clusters.Identify +local PowerConfiguration = clusters.PowerConfiguration +local Groups = clusters.Groups +local IASZone = clusters.IASZone +local IASWD = clusters.IASWD +local IaswdLevel = IASWD.types.IaswdLevel +local WarningMode = IASWD.types.WarningMode +local SquawkMode = IASWD.types.SquawkMode +local SirenConfiguration = IASWD.types.SirenConfiguration +local SquawkConfiguration = IASWD.types.SquawkConfiguration +local ZoneStatusAttribute = IASZone.attributes.ZoneStatus + +local PRIMARY_SW_VERSION = "primary_sw_version" +local SIREN_ENDIAN = "siren_endian" +local ALARM_DURATION = "warningDuration" +local ALARM_DEFAULT_MAX_DURATION = 0x00F0 +local ALARM_DURATION_TEST_VALUE = 5 +local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 +local DEVELCO_MANUFACTURER_CODE = 0x1015 + +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local data_types = require "st.zigbee.data_types" +local t_utils = require "integration_test.utils" + + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-siren-battery-source-tamper.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "SIRZB-111", + server_clusters = { Scenes.ID, OnOff.ID} + }, + [0x2B] = { + id = 0x2B, + server_clusters = { Basic.ID, Identify.ID, PowerConfiguration.ID, Groups.ID, IASZone.ID, IASWD.ID } + } + } + } +) + +local mock_device_112 = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("frient-siren-battery-source-tamper.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "frient A/S", + model = "SIRZB-112", + server_clusters = { Scenes.ID, OnOff.ID} + }, + [0x2B] = { + id = 0x2B, + server_clusters = { Basic.ID, Identify.ID, PowerConfiguration.ID, Groups.ID, IASZone.ID, IASWD.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +local function set_new_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "010903", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function set_older_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, "010901", {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function set_no_firmware_and_defaults() + -- set the firmware version and endian format for testing + mock_device:set_field(PRIMARY_SW_VERSION, nil, {persist = true}) + mock_device:set_field(SIREN_ENDIAN, nil, {persist = true}) + -- set test durations and parameters + mock_device:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) +end + +local function get_siren_commands_new_fw(warningMode, sirenLevel, duration) + local expectedSirenONConfiguration = SirenConfiguration(0x00) + expectedSirenONConfiguration:set_warning_mode(warningMode) --WarningMode.BURGLAR + expectedSirenONConfiguration:set_siren_level(sirenLevel) --IaswdLevel.VERY_HIGH_LEVEL + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenONConfiguration, + data_types.Uint16(duration), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_siren_commands_old_fw(warningMode, sirenLevel) + local expectedSirenONConfiguration + local siren_config_value = (sirenLevel << 6) | warningMode + expectedSirenONConfiguration = SirenConfiguration(siren_config_value) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenONConfiguration, + data_types.Uint16(ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_siren_OFF_commands(duration) + local expectedSirenOFFConfiguration = SirenConfiguration(0x00) + expectedSirenOFFConfiguration:set_warning_mode(WarningMode.STOP) + expectedSirenOFFConfiguration:set_siren_level(IaswdLevel.LOW_LEVEL) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.StartWarning( + mock_device, + expectedSirenOFFConfiguration, + data_types.Uint16(duration and duration or ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +local function get_squawk_command_new_fw(squawk_mode, squawk_siren_level) + local expected_squawk_configuration = SquawkConfiguration(0x00) + expected_squawk_configuration:set_squawk_mode(squawk_mode) + expected_squawk_configuration:set_squawk_level(squawk_siren_level) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.Squawk( + mock_device, + expected_squawk_configuration + ) + }) +end + +local function get_squawk_command_older_fw(squawk_mode, squawk_siren_level) + local expected_squawk_configuration + local squawk_config_value = (squawk_siren_level << 6) | squawk_mode + expected_squawk_configuration = SquawkConfiguration(squawk_config_value) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.server.commands.Squawk( + mock_device, + expected_squawk_configuration + ) + }) +end + +test.register_coroutine_test( + "lifecycles - init and doConfigure test", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write( + mock_device, + ALARM_DEFAULT_MAX_DURATION + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + PowerConfiguration.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting( + mock_device, + 30, + 21600, + 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request( + mock_device, + zigbee_test_utils.mock_hub_eui, + IASZone.ID, + 0x2B + ):to_endpoint(0x2B) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.ZoneStatus:configure_reporting( + mock_device, + 0, + 21600, + 1 + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.attributes.IASCIEAddress:write( + mock_device, + zigbee_test_utils.mock_hub_eui + ) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASZone.server.commands.ZoneEnrollResponse( + mock_device, + IasEnrollResponseCode.SUCCESS, + 0x00 + ) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "lifecycle - added test", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.supportedModes({ "Burglar", "Fire", "Emergency", "Panic", "Panic Fire", "Panic Emergency" }, { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.supportedArguments({ "Burglar", "Fire", "Emergency", "Panic", "Panic Fire", "Panic Emergency" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVoice", + capabilities.mode.mode("Burglar") + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.supportedModes({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.supportedArguments({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SirenVolume", + capabilities.mode.mode({value = "Very High"}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.supportedModes({ "Armed", "Disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.supportedArguments({ "Armed", "Disarmed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVoice", + capabilities.mode.mode("Armed") + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.supportedModes({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.supportedArguments({ "Low", "Medium", "High", "Very High" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "SquawkVolume", + capabilities.mode.mode("Very High") + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.supportedModes( + { + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.supportedArguments( + { + "5 seconds", "10 seconds", "15 seconds", "20 seconds", "25 seconds", "30 seconds", "40 seconds", "50 seconds", + "1 minute", "2 minutes", "3 minutes", "4 minutes", "5 minutes", "10 minutes" + }, + { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "WarningDuration", + capabilities.mode.mode("4 minutes") + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.alarm.alarm.off() + ) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.tamperAlert.tamper.clear() + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on the siren (test with default settings)", + function() + set_new_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "010903", "PRIMARY_SW_VERSION should be greater than or equal to '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.BURGLAR,IaswdLevel.VERY_HIGH_LEVEL,ALARM_DURATION_TEST_VALUE) + test.mock_time.advance_time(ALARM_DURATION_TEST_VALUE) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on the siren (test with default settings)", + function() + set_older_firmware_and_defaults() + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "010903", "PRIMARY_SW_VERSION should be lower than '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_old_fw(WarningMode.BURGLAR,IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(ALARM_DURATION_TEST_VALUE) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Alarm OFF should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + get_siren_OFF_commands() + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"5 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("5 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL,ALARM_DURATION_TEST_VALUE) + test.mock_time.advance_time(5) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled - in case of NO FW version was reported before", + function() + set_no_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"5 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("5 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL,ALARM_DURATION_TEST_VALUE) + + test.mock_time.advance_time(5) + -- stop the siren + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the OFF command + get_siren_OFF_commands() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SirenVoice mode 'Fire' and SirenVolume mode 'LOW' should be handled - when maxWarningDuration is shorted then selected mode", + function() + local expectedWarningDuration = 7 + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(expectedWarningDuration, "oneshot") + + test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed( + { + preferences = { + maxWarningDuration = expectedWarningDuration + } + } + )) + + test.socket.zigbee:__expect_send({ + mock_device.id, + IASWD.attributes.MaxDuration:write( + mock_device, + expectedWarningDuration + ) + }) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVoice", command = "setMode", args = {"Fire"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVoice", capabilities.mode.mode("Fire")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SirenVolume", command = "setMode", args = {"Low"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SirenVolume", capabilities.mode.mode("Low")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "WarningDuration", command = "setMode", args = {"50 seconds"} } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("WarningDuration", capabilities.mode.mode("50 seconds")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_siren_commands_new_fw(WarningMode.FIRE,IaswdLevel.LOW_LEVEL, expectedWarningDuration) + + test.mock_time.advance_time(expectedWarningDuration) + -- stop the siren + -- Expect the OFF command + get_siren_OFF_commands(expectedWarningDuration) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Should detect newer firmware version and use correct endian format to turn on squawk (test with default settings)", + function() + set_new_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) >= "010903", "PRIMARY_SW_VERSION should be greater than or equal to '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_new_fw( SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED, IaswdLevel.VERY_HIGH_LEVEL ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Should detect older firmware version and use correct endian format to turn on squawk (test with default settings)", + function() + set_older_firmware_and_defaults() + + -- Verify fields are set correctly + assert(mock_device:get_field(PRIMARY_SW_VERSION) < "010903", "PRIMARY_SW_VERSION should be lower than '010903'") + + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Test the siren command with reversed endian + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_older_fw( SquawkMode.SOUND_FOR_SYSTEM_IS_ARMED, IaswdLevel.VERY_HIGH_LEVEL ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled", + function() + set_new_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_new_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled - in case of OLD FW version was reported before", + function() + set_older_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Expect the command with given configuration + get_squawk_command_older_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "SquawkVoice mode 'Disarmed' and SquawkVolume mode 'Medium' should be handled - in case of NO FW version was reported before", + function() + set_no_firmware_and_defaults() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVoice", command = "setMode", args = { "Disarmed" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVoice", capabilities.mode.mode("Disarmed")) + ) + + test.wait_for_events() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "SquawkVolume", command = "setMode", args = { "Medium" } } + }) + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("SquawkVolume", capabilities.mode.mode("Medium")) + ) + + test.wait_for_events() + -- Test siren with update configuration + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "tone", component = "main", command = "beep", args = {} } + }) + test.mock_time.advance_time(1) + -- Read the FW version first + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ):to_endpoint(0x2B) + } + ) + -- Expect the command with given configuration + get_squawk_command_new_fw(SquawkMode.SOUND_FOR_SYSTEM_IS_DISARMED, IaswdLevel.MEDIUM_LEVEL) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Refresh should be handled - new FW", + function() + set_new_firmware_and_defaults() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Refresh should be handled - FW not known", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + cluster_base.read_manufacturer_specific_attribute( + mock_device, + Basic.ID, + DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR, + DEVELCO_MANUFACTURER_CODE + ) + } + ) + + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Power source / mains should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0005) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 14 + } +) + +test.register_message_test( + "Power source / battery should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, ZoneStatusAttribute:build_test_attr_report(mock_device, 0x0081) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 14 + } +) + +test.register_message_test( + "Min battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0xC8) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Medium battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0x64) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Max battery voltage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 0) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) + } + }, + { + min_api_version = 14 + } +) + +local function get_siren_OFF_commands_112(duration) + local expectedSirenOFFConfiguration = SirenConfiguration(0x00) + expectedSirenOFFConfiguration:set_warning_mode(WarningMode.STOP) + expectedSirenOFFConfiguration:set_siren_level(IaswdLevel.LOW_LEVEL) + + test.socket.zigbee:__expect_send({ + mock_device_112.id, + IASWD.server.commands.StartWarning( + mock_device_112, + expectedSirenOFFConfiguration, + data_types.Uint16(duration and duration or ALARM_DURATION_TEST_VALUE), + data_types.Uint8(0x00), + data_types.Enum8(0x00) + ) + }) +end + +test.register_coroutine_test( + "SIRZB-112 alarm off command should be handled via frient sub-driver", + function() + mock_device_112:set_field(PRIMARY_SW_VERSION, "010903", {persist = true}) + mock_device_112:set_field(ALARM_DURATION, ALARM_DURATION_TEST_VALUE, {persist = true}) + + test.socket.capability:__queue_receive({ + mock_device_112.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + get_siren_OFF_commands_112() + test.wait_for_events() + end, + { test_init = function() + test.mock_device.add_test_device(mock_device_112) + end, + min_api_version = 14 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_multiir_zigbee_siren_tamper.lua b/drivers/SmartThings/zigbee-siren/src/test/test_multiir_zigbee_siren_tamper.lua new file mode 100644 index 0000000000..24dc8766f3 --- /dev/null +++ b/drivers/SmartThings/zigbee-siren/src/test/test_multiir_zigbee_siren_tamper.lua @@ -0,0 +1,312 @@ +-- 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. + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local OnOff = clusters.OnOff +local IASZone = clusters.IASZone +local IASWD = clusters.IASWD +local SirenConfiguration = IASWD.types.SirenConfiguration +local DEFAULT_MAX_WARNING_DURATION = 1800 +local ALARM_STROBE_DUTY_CYCLE = 40 +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local data_types = require "st.zigbee.data_types" +local t_utils = require "integration_test.utils" + +local IaswdLevel = IASWD.types.IaswdLevel +local WarningMode = IASWD.types.WarningMode +local Strobe = IASWD.types.Strobe + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("switch-alarm-tamper-warningduration-volume-no-fw-update.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "MultIR", + model = "MIR-SR100", + server_clusters = { IASWD.ID, IASZone.ID,OnOff.ID} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.switch.switch.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.alarm.alarm.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tamperAlert.tamper.clear())) + test.socket.zigbee:__expect_send({ mock_device.id, + IASWD.attributes.MaxDuration:read(mock_device)}) + end, + { + min_api_version = 19 + } +) + +local function send_startWarning( device, WarningMode, sirenVolume_msg, warning_duration, strobe_active, strobe_level ) + local expectedSirenONConfiguration = SirenConfiguration(0x00) + expectedSirenONConfiguration:set_warning_mode(WarningMode) --WarningMode.BURGLAR + expectedSirenONConfiguration:set_siren_level(sirenVolume_msg) --IaswdLevel.VERY_HIGH_LEVEL + expectedSirenONConfiguration:set_strobe(strobe_active) + + test.socket.zigbee:__expect_send({ + device.id, + IASWD.server.commands.StartWarning( + device, + expectedSirenONConfiguration, + data_types.Uint16(warning_duration), + data_types.Uint8(ALARM_STROBE_DUTY_CYCLE), + data_types.Enum8(strobe_level) + ) + }) +end + +test.register_coroutine_test( + "Capability(switch) command(on) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } + }) + + local sirenVolume_msg = tonumber(mock_device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(mock_device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + send_startWarning(mock_device, WarningMode.BURGLAR, sirenVolume_msg, warning_duration, Strobe.USE_STROBE, IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(warning_duration) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(switch) command(off) should be handled", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } + }) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(alarm) command(both) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "both", args = {} } + }) + + local sirenVolume_msg = tonumber(mock_device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(mock_device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + send_startWarning(mock_device, WarningMode.BURGLAR, sirenVolume_msg, warning_duration, Strobe.USE_STROBE, IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(warning_duration) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(alarm) command(off) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(alarm) command(siren) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "siren", args = {} } + }) + + local sirenVolume_msg = tonumber(mock_device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(mock_device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + send_startWarning(mock_device, WarningMode.BURGLAR, sirenVolume_msg, warning_duration, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + test.mock_time.advance_time(warning_duration) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability(alarm) command(strobe) should be handled", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "alarm", component = "main", command = "strobe", args = {} } + }) + + local sirenVolume_msg = tonumber(mock_device.preferences.sirenVolume) or IaswdLevel.VERY_HIGH_LEVEL + local warning_duration = tonumber(mock_device.preferences.warningDuration) or DEFAULT_MAX_WARNING_DURATION + send_startWarning(mock_device, WarningMode.STOP, sirenVolume_msg, warning_duration, Strobe.USE_STROBE, IaswdLevel.VERY_HIGH_LEVEL) + test.mock_time.advance_time(warning_duration) + send_startWarning(mock_device, WarningMode.STOP, IaswdLevel.LOW_LEVEL, DEFAULT_MAX_WARNING_DURATION, Strobe.NO_STROBE, IaswdLevel.LOW_LEVEL) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + IASZone.attributes.ZoneStatus:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0004, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0004) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_ozom_siren.lua b/drivers/SmartThings/zigbee-siren/src/test/test_ozom_siren.lua index 5b9a6a2bea..70eccc578f 100644 --- a/drivers/SmartThings/zigbee-siren/src/test/test_ozom_siren.lua +++ b/drivers/SmartThings/zigbee-siren/src/test/test_ozom_siren.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2022 SmartThings, Inc. -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -60,6 +60,9 @@ test.register_message_test( data_types.Enum8(0)) } } + }, + { + min_api_version = 14 } ) @@ -94,7 +97,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -118,7 +124,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-siren/src/test/test_zigbee_siren.lua b/drivers/SmartThings/zigbee-siren/src/test/test_zigbee_siren.lua index e8de7eb958..3750412d19 100644 --- a/drivers/SmartThings/zigbee-siren/src/test/test_zigbee_siren.lua +++ b/drivers/SmartThings/zigbee-siren/src/test/test_zigbee_siren.lua @@ -19,6 +19,7 @@ local zcl_cmds = require "st.zigbee.zcl.global_commands" local IASZone = clusters.IASZone local IASWD = clusters.IASWD local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local data_types = require "st.zigbee.data_types" @@ -66,6 +67,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(3)) } } + }, + { + min_api_version = 17 } ) @@ -86,6 +90,9 @@ test.register_message_test( data_types.Uint8(0x28), data_types.Enum8(0)) } } + }, + { + min_api_version = 17 } ) @@ -106,6 +113,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(3)) } } + }, + { + min_api_version = 17 } ) @@ -126,6 +136,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(0)) } } + }, + { + min_api_version = 17 } ) @@ -146,6 +159,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(0)) } } + }, + { + min_api_version = 17 } ) @@ -166,6 +182,9 @@ test.register_message_test( data_types.Uint8(40), data_types.Enum8(3)) } } + }, + { + min_api_version = 17 } ) @@ -212,9 +231,26 @@ test.register_coroutine_test( mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -235,6 +271,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 17 } ) @@ -265,9 +304,18 @@ test.register_message_test( OnOff.attributes.OnOff:read(mock_device) } }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + } + } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 17 } ) @@ -314,7 +362,76 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.siren())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 17 + } +) + +local function build_default_response_zigbee_msg() + local zcl_messages = require "st.zigbee.zcl" + local messages = require "st.zigbee.messages" + local zb_const = require "st.zigbee.constants" + local buf_lib = require "st.buf" + local buf_from_str = function(str) return buf_lib.Reader(str) end + local frame = "\x00\x00" + local default_response = zcl_cmds.DefaultResponse.deserialize(buf_from_str(frame)) + local zclh = zcl_messages.ZclHeader({ cmd = data_types.ZCLCommandId(zcl_cmds.DefaultResponse.ID) }) + local addrh = messages.AddressHeader( + mock_device:get_short_address(), + mock_device:get_endpoint(data_types.ClusterId(IASWD.ID)), + zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, zb_const.HA_PROFILE_ID, IASWD.ID) + local message_body = zcl_messages.ZclMessageBody({ zcl_header = zclh, zcl_body = default_response }) + return messages.ZigbeeMessageRx({ address_header = addrh, body = message_body }) +end + +test.register_coroutine_test( + "Default response with OFF alarm command should emit off events", + function() + mock_device:set_field("alarmCommand", 0, {persist = true}) + test.socket.zigbee:__queue_receive({ mock_device.id, build_default_response_zigbee_msg() }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Default response with STROBE alarm command should emit strobe event and timer off", + function() + test.timer.__create_and_queue_test_time_advance_timer(180, "oneshot") + mock_device:set_field("alarmCommand", 2, {persist = true}) + test.socket.zigbee:__queue_receive({ mock_device.id, build_default_response_zigbee_msg() }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.strobe())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.mock_time.advance_time(180) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Default response with BOTH alarm command should emit both event", + function() + test.timer.__create_and_queue_test_time_advance_timer(180, "oneshot") + mock_device:set_field("alarmCommand", 3, {persist = true}) + test.socket.zigbee:__queue_receive({ mock_device.id, build_default_response_zigbee_msg() }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.both())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.mock_time.advance_time(180) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.alarm.alarm.off())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -333,7 +450,10 @@ test.register_coroutine_test( data_types.Uint16(0x0032), data_types.Uint8(40), data_types.Enum8(0)) }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml b/drivers/SmartThings/zigbee-smoke-detector/fingerprints.yml index ddd1135cbd..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 @@ -54,3 +54,13 @@ zigbeeManufacturer: manufacturer: HEIMAN model: GASSensor-N deviceProfileName: smoke-detector + - id: "MultIR/MIR-SM200" + deviceLabel: MultiIR Smoke Detector MIR-SM200 + 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/profiles/smoke-battery-tamper-no-fw-update.yml b/drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-battery-tamper-no-fw-update.yml new file mode 100644 index 0000000000..eb66ce92c4 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/profiles/smoke-battery-tamper-no-fw-update.yml @@ -0,0 +1,14 @@ +name: smoke-battery-tamper-no-fw-update +components: +- id: main + capabilities: + - id: smokeDetector + version: 1 + - id: tamperAlert + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: SmokeDetector diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/can_handle.lua new file mode 100644 index 0000000000..d389619c78 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "MultiIR.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("MultiIR") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/fingerprints.lua b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/fingerprints.lua new file mode 100644 index 0000000000..3d845bdf7e --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "MultIR", model = "MIR-SM200" } +} diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/init.lua new file mode 100644 index 0000000000..b382a56cec --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/MultiIR/init.lua @@ -0,0 +1,53 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zcl_clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" + +local IASZone = zcl_clusters.IASZone + +local function generate_event_from_zone_status(driver, device, zone_status, zb_rx) + if zone_status:is_alarm1_set() then + device:emit_event(capabilities.smokeDetector.smoke.detected()) + elseif zone_status:is_alarm2_set() then + device:emit_event(capabilities.smokeDetector.smoke.tested()) + else + device:emit_event(capabilities.smokeDetector.smoke.clear()) + end + if device:supports_capability(capabilities.tamperAlert) then + device:emit_event(zone_status:is_tamper_set() and capabilities.tamperAlert.tamper.detected() or capabilities.tamperAlert.tamper.clear()) + end +end + +local function ias_zone_status_change_handler(driver, device, zb_rx) + local zone_status = zb_rx.body.zcl_body.zone_status + generate_event_from_zone_status(driver, device, zone_status, zb_rx) +end + +local function added_handler(self, device) + device:emit_event(capabilities.battery.battery(100)) + device:emit_event(capabilities.smokeDetector.smoke.clear()) + device:emit_event(capabilities.tamperAlert.tamper.clear()) +end + +local MultiIR_smoke_detector_handler = { + NAME = "MultiIR Smoke Detector Handler", + lifecycle_handlers = { + added = added_handler + }, + zigbee_handlers = { + cluster = { + [IASZone.ID] = { + [IASZone.client.commands.ZoneStatusChangeNotification.ID] = ias_zone_status_change_handler + } + }, + attr = { + [IASZone.ID] = { + [IASZone.attributes.ZoneStatus.ID] = generate_event_from_zone_status + } + } + }, + can_handle = require("MultiIR.can_handle") +} + +return MultiIR_smoke_detector_handler diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/can_handle.lua new file mode 100644 index 0000000000..41ba568b80 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara-gas.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara-gas") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/fingerprints.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/fingerprints.lua new file mode 100644 index 0000000000..61cc4d9730 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.sensor_gas.acn02" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/init.lua index 297701272d..e0474243ce 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/init.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara-gas/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" local cluster_base = require "st.zigbee.cluster_base" local capabilities = require "st.capabilities" @@ -31,9 +21,6 @@ local PRIVATE_LIFE_TIME_ATTRIBUTE_ID = 0x0128 local PRIVATE_GAS_ZONE_STATUS_ATTRIBUTE_ID = 0x013A -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.sensor_gas.acn02" } -} local CONFIGURATIONS = { @@ -125,14 +112,6 @@ local function self_check_attr_handler(self, device, zone_status, zb_rx) PRIVATE_CLUSTER_ID, PRIVATE_SELF_CHECK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true)) end -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) if CONFIGURATIONS ~= nil then @@ -186,8 +165,7 @@ local aqara_gas_detector_handler = { [startSelfCheckCommandName] = self_check_attr_handler }, }, - can_handle = is_aqara_products + can_handle = require("aqara-gas.can_handle"), } return aqara_gas_detector_handler - diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..d296aa8518 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.sensor_smoke.acn03" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/aqara/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/init.lua index 91aba0a5e9..1950d1f923 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/aqara/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -30,9 +20,6 @@ local PRIVATE_SMOKE_ZONE_STATUS_ATTRIBUTE_ID = 0x013A local PowerConfiguration = clusters.PowerConfiguration -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.sensor_smoke.acn03" } -} local CONFIGURATIONS = { @@ -98,14 +85,6 @@ local function self_check_attr_handler(self, device, zone_status, zb_rx) PRIVATE_CLUSTER_ID, PRIVATE_SELF_CHECK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true)) end -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) @@ -154,8 +133,7 @@ local aqara_gas_detector_handler = { [startSelfCheckCommandName] = self_check_attr_handler }, }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_gas_detector_handler - diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-smoke-detector/src/frient/can_handle.lua new file mode 100644 index 0000000000..5154ec5fe6 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/frient/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and (device:get_model() == "SMSZB-120" or device:get_model() == "HESZB-120") then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/frient/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/frient/init.lua index 1d6d85311a..d3a3604c26 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/frient/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local battery_defaults = require "st.zigbee.defaults.battery_defaults" local capabilities = require "st.capabilities" @@ -270,8 +260,6 @@ local frient_smoke_sensor = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" and (device:get_model() == "SMSZB-120" or device:get_model() == "HESZB-120") - end + can_handle = require("frient.can_handle"), } return frient_smoke_sensor diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/init.lua b/drivers/SmartThings/zigbee-smoke-detector/src/init.lua index 941c3312da..0f1ba0720b 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/init.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -25,16 +15,13 @@ local zigbee_smoke_driver_template = { capabilities.temperatureMeasurement, capabilities.temperatureAlarm }, - sub_drivers = { - require("frient"), - require("aqara-gas"), - require("aqara") - }, + sub_drivers = require("sub_drivers"), ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_smoke_driver_template, zigbee_smoke_driver_template.supported_capabilities, {native_capability_attrs_enabled = true}) local zigbee_smoke_driver = ZigbeeDriver("zigbee-smoke-detector", zigbee_smoke_driver_template) -zigbee_smoke_driver:run() \ No newline at end of file +zigbee_smoke_driver:run() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-smoke-detector/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end 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 new file mode 100644 index 0000000000..2f7a4b29ff --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/sub_drivers.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("frient"), + 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/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_gas_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_gas_detector.lua index 6c194351a6..47e10b7923 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_gas_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_gas_detector.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -74,7 +63,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.High())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", selfCheck.selfCheckState.idle())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", lifeTimeReport.lifeTimeState.normal())) - end + end, + { + min_api_version = 14 + } ) @@ -92,7 +84,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, config_attr_message}) test.socket.zigbee:__expect_send({mock_device.id, write_attr_messge}) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) @@ -108,7 +103,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.gasDetector.gas.detected())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -123,7 +121,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.gasDetector.gas.clear())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -138,7 +139,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.muted())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -153,7 +157,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.unmuted())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -164,7 +171,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_MUTE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -174,7 +184,10 @@ test.register_coroutine_test( { capability = "audioMute", component = "main", command = "unmute", args = {} } }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.muted())) - end + end, + { + min_api_version = 14 + } ) @@ -191,7 +204,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", selfCheck.selfCheckState.selfCheckCompleted())) - end + end, + { + min_api_version = 14 + } ) @@ -205,7 +221,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_SELF_CHECK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) @@ -222,7 +241,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", lifeTimeReport.lifeTimeState.endOfLife())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -237,7 +259,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", lifeTimeReport.lifeTimeState.normal())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -252,9 +277,81 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Low())) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "selfCheck report should be handled, idle", + function() + local attr_report_data = { + { PRIVATE_SELF_CHECK_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + selfCheck.selfCheckState.idle())) + end, + { + min_api_version = 14 + } ) +test.register_coroutine_test( + "sensitivityAdjustment report should be handled, High", + function() + local attr_report_data = { + { PRIVATE_SENSITIVITY_ADJUSTMENT_ATTRIBUTE_ID, data_types.Uint8.ID, 0x02 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + sensitivityAdjustment.sensitivityAdjustment.High())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Capability on command should be handled : setSensitivityAdjustment High", + function() + local attr_report_data = { + { PRIVATE_SENSITIVITY_ADJUSTMENT_ATTRIBUTE_ID, data_types.Uint8.ID, 0x02 } + } + test.socket.capability:__queue_receive({ mock_device.id, + { capability = sensitivityAdjustmentId, component = "main", command = "setSensitivityAdjustment", args = {"High"}} + }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PRIVATE_SENSITIVITY_ADJUSTMENT_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x02) + }) + test.wait_for_events() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + sensitivityAdjustment.sensitivityAdjustment.High()) + ) + test.wait_for_events() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = sensitivityAdjustmentId, component = "main", command = "setSensitivityAdjustment", args = {"High"}} + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + sensitivityAdjustment.sensitivityAdjustment.High()) + ) + end, + { + min_api_version = 14 + } +) test.register_coroutine_test( @@ -285,7 +382,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", sensitivityAdjustment.sensitivityAdjustment.Low()) ) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_smoke_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_smoke_detector.lua index bd0fae534c..31751312c7 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_smoke_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_aqara_smoke_detector.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -18,13 +7,10 @@ local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" - - local PowerConfiguration = clusters.PowerConfiguration local selfCheck = capabilities["stse.selfCheck"] local selfCheckId = "stse.selfCheck" - test.add_package_capability("selfCheck.yaml") local PRIVATE_CLUSTER_ID = 0xFCC0 @@ -34,8 +20,6 @@ local PRIVATE_MUTE_ATTRIBUTE_ID = 0x0126 local PRIVATE_SELF_CHECK_ATTRIBUTE_ID = 0x0127 local PRIVATE_SMOKE_ZONE_STATUS_ATTRIBUTE_ID = 0x013A - - local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("smoke-battery-aqara.yml"), @@ -50,15 +34,11 @@ local mock_device = test.mock_device.build_test_zigbee_device( } ) - - zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) end - - test.set_test_init_function(test_init) test.register_coroutine_test( @@ -69,7 +49,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.unmuted())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", selfCheck.selfCheckState.idle())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(100))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -97,10 +80,12 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) - test.register_coroutine_test( "smokeDetector report should be handled", function() @@ -113,11 +98,12 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected())) - end + end, + { + min_api_version = 14 + } ) - - test.register_coroutine_test( "audioMute report should be handled", function() @@ -130,11 +116,12 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.audioMute.mute.muted())) - end + end, + { + min_api_version = 14 + } ) - - test.register_coroutine_test( "Capability on command should be handled : device mute", function() @@ -143,11 +130,12 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_MUTE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - end + end, + { + min_api_version = 14 + } ) - - test.register_coroutine_test( "selfCheck report should be handled", function() @@ -160,11 +148,12 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", selfCheck.selfCheckState.selfCheckCompleted())) - end + end, + { + min_api_version = 14 + } ) - - test.register_coroutine_test( "Capability on command should be handled : device selfCheck", function() @@ -174,10 +163,65 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_SELF_CHECK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) +test.register_coroutine_test( + "smokeDetector report should be handled, smoke clear", + function() + local attr_report_data = { + { PRIVATE_SMOKE_ZONE_STATUS_ATTRIBUTE_ID, data_types.Uint16.ID, 0x0000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.smokeDetector.smoke.clear())) + end, + { + min_api_version = 14 + } +) +test.register_coroutine_test( + "audioMute report should be handled, unmuted", + function() + local attr_report_data = { + { PRIVATE_MUTE_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.audioMute.mute.unmuted())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "selfCheck report should be handled, idle", + function() + local attr_report_data = { + { PRIVATE_SELF_CHECK_ATTRIBUTE_ID, data_types.Uint8.ID, 0x00 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + selfCheck.selfCheckState.idle())) + end, + { + min_api_version = 14 + } +) test.register_message_test( "Battery voltage report should be handled", @@ -192,8 +236,10 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) - test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_heat_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_heat_detector.lua index aec09a8435..6512e3504f 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_heat_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_heat_detector.lua @@ -1,16 +1,5 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -85,7 +74,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -204,7 +196,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -220,6 +215,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) } + }, + { + min_api_version = 14 } ) @@ -236,7 +234,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -252,7 +253,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -270,7 +274,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -294,6 +301,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -334,7 +344,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -369,7 +380,10 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -402,7 +416,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -436,7 +453,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -469,7 +489,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -503,7 +526,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -577,7 +603,48 @@ test.register_coroutine_test( string.format("Version mismatch! Expected '%s' but got '%s'", expected_hex, stored_version or "nil")) end - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "IASZone attribute report should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "IASZone attribute report should be handled: cleared", + function() + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) + }) + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) + ) + test.wait_for_events() + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_smoke_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_smoke_detector.lua index 574f08ae76..f9a60d24c4 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_smoke_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_frient_smoke_detector.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -39,6 +28,12 @@ local DEVELCO_BASIC_PRIMARY_SW_VERSION_ATTR = 0x8000 local DEVELCO_MANUFACTURER_CODE = 0x1015 local cluster_base = require "st.zigbee.cluster_base" local defaultWarningDuration = 240 +local messages = require "st.zigbee.messages" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local zb_const = require "st.zigbee.constants" +local Status = require "st.zigbee.generated.types.ZclStatus" +local zcl_messages = require "st.zigbee.zcl" +local ALARM_COMMAND = "alarmCommand" local mock_device = test.mock_device.build_test_zigbee_device( @@ -86,7 +81,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -205,7 +203,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -221,6 +222,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(14)) } + }, + { + min_api_version = 14 } ) @@ -238,6 +242,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -255,6 +262,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) } + }, + { + min_api_version = 14 } ) @@ -273,7 +283,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -297,6 +310,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -374,7 +390,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -409,7 +426,10 @@ test.register_coroutine_test( test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -442,7 +462,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -476,7 +499,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -509,7 +535,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -543,7 +572,10 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -617,7 +649,106 @@ test.register_coroutine_test( string.format("Version mismatch! Expected '%s' but got '%s'", expected_hex, stored_version or "nil")) end - end + end, + { + min_api_version = 14 + } +) + +local function build_default_response_msg(device, cluster, command, status) + local addr_header = messages.AddressHeader( + device:get_short_address(), + device.fingerprinted_endpoint_id, + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + zb_const.HA_PROFILE_ID, + cluster + ) + local default_response_body = default_response.DefaultResponse(command, status) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(default_response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = default_response_body + }) + return messages.ZigbeeMessageRx({ + address_header = addr_header, + body = message_body + }) +end + +test.register_message_test( + "IASZone attribute report should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0001) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", smokeDetector.smoke.detected()) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "IASZone attribute report should be handled: clear", + function() + test.timer.__create_and_queue_test_time_advance_timer(6, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) + }) + test.mock_time.advance_time(6) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", smokeDetector.smoke.clear()) + ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "default_response_handler: siren active, emit siren then off after delay", + function() + mock_device:set_field(ALARM_COMMAND, 1) + test.timer.__create_and_queue_test_time_advance_timer(ALARM_DEFAULT_MAX_DURATION, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_msg(mock_device, IASWD.ID, IASWD.server.commands.StartWarning.ID, Status.SUCCESS) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", alarm.alarm.siren())) + test.mock_time.advance_time(ALARM_DEFAULT_MAX_DURATION) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", alarm.alarm.off())) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "default_response_handler: alarm off, emit off", + function() + mock_device:set_field(ALARM_COMMAND, 0) + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_msg(mock_device, IASWD.ID, IASWD.server.commands.StartWarning.ID, Status.SUCCESS) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", alarm.alarm.off())) + test.wait_for_events() + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_multiir_smoke_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_multiir_smoke_detector.lua new file mode 100644 index 0000000000..e79df95f01 --- /dev/null +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_multiir_smoke_detector.lua @@ -0,0 +1,195 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local IASZone = clusters.IASZone + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("smoke-battery-tamper-no-fw-update.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "MultIR", + model = "MIR-SM200", + server_clusters = { 0x0001,0x0020, 0x0500, 0x0502 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.battery.battery(100))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.smokeDetector.smoke.clear())) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.tamperAlert.tamper.clear())) + end, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: smoke/clear tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: smoke/detected tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0005) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Reported ZoneStatus should be handled: smoke/tested tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0006) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: smoke/detected tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0005, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: smoke/tested tamper/detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0006, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: smoke/clear tamper/clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + } + }, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_zigbee_smoke_detector.lua b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_zigbee_smoke_detector.lua index 00d0b43c10..1afb849207 100644 --- a/drivers/SmartThings/zigbee-smoke-detector/src/test/test_zigbee_smoke_detector.lua +++ b/drivers/SmartThings/zigbee-smoke-detector/src/test/test_zigbee_smoke_detector.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -54,6 +43,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "smokeDetector", capability_attr_id = "smoke" } } }, + }, + { + min_api_version = 14 } ) @@ -78,6 +70,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "smokeDetector", capability_attr_id = "smoke" } } }, + }, + { + min_api_version = 14 } ) @@ -102,6 +97,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "smokeDetector", capability_attr_id = "smoke" } } }, + }, + { + min_api_version = 14 } ) @@ -126,6 +124,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "smokeDetector", capability_attr_id = "smoke" } } }, + }, + { + min_api_version = 14 } ) @@ -142,6 +143,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -239,7 +243,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -276,7 +283,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-sound-sensor/src/test/test_zigbee_sound_sensor.lua b/drivers/SmartThings/zigbee-sound-sensor/src/test/test_zigbee_sound_sensor.lua index 4ab2f31e92..90e1369935 100644 --- a/drivers/SmartThings/zigbee-sound-sensor/src/test/test_zigbee_sound_sensor.lua +++ b/drivers/SmartThings/zigbee-sound-sensor/src/test/test_zigbee_sound_sensor.lua @@ -121,7 +121,10 @@ test.register_coroutine_test( PollControl.attributes.CheckInInterval:write(mock_device, data_types.Uint32(6480)) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -187,7 +190,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -204,6 +208,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.detected()) } + }, + { + min_api_version = 14 } ) @@ -220,6 +227,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.detected()) } + }, + { + min_api_version = 14 } ) @@ -236,6 +246,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.not_detected()) } + }, + { + min_api_version = 14 } ) @@ -252,6 +265,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -276,6 +292,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -297,6 +316,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index 3d598372fa..613c471dcb 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -111,6 +111,11 @@ zigbeeManufacturer: manufacturer: LUMI model: lumi.switch.n0acn2 deviceProfileName: aqara-switch-module + - id: "Aqara/lumi.switch.acn047" + deviceLabel: Aqara Dual Relay Module T2 (With Neutral) 1 + manufacturer: Aqara + model: lumi.switch.acn047 + deviceProfileName: aqara-dual-relay-module-unified - id: "LUMI/lumi.light.acn004" deviceLabel: Aqara Smart Dimmer Controller T1 Pro manufacturer: LUMI @@ -505,6 +510,11 @@ zigbeeManufacturer: manufacturer: frient A/S model: SMRZB-342 deviceProfileName: frient-switch-power-energy-voltage + - id: "frient/IOMZB-110" + deviceLabel: frient IO Module + manufacturer: frient A/S + model: IOMZB-110 + deviceProfileName: switch-4inputs-2outputs - id: "AduroSmart Eria/AD-DimmableLight3001" deviceLabel: Eria Light manufacturer: AduroSmart Eria @@ -595,6 +605,11 @@ zigbeeManufacturer: manufacturer: ubisys model: "D1 (5503)" deviceProfileName: switch-level-power + - id: "ubisys/S1 (5501)" + deviceLabel: Switching Actuator S1 + manufacturer: ubisys + model: "S1 (5501)" + deviceProfileName: switch-power - id: "ubisys/S2 (5502)" deviceLabel: ubisys S2 manufacturer: ubisys @@ -605,6 +620,11 @@ zigbeeManufacturer: manufacturer: ubisys model: "1151" deviceProfileName: switch-power + - id: "ubisys/D1-R (5603)" + deviceLabel: Dimmer D1-R + manufacturer: ubisys + model: "D1-R (5603)" + deviceProfileName: switch-level-power - id: "Megaman/AD-DimmableLight3001" deviceLabel: INGENIUM Light manufacturer: Megaman @@ -1715,6 +1735,16 @@ zigbeeManufacturer: manufacturer: LEDVANCE model: RT TW deviceProfileName: color-temp-bulb + - id: "LEDVANCE/PLUG COMPACT EU EM T" + deviceLabel: SMART ZIGBEE COMPACT OUTDOOR PLUG EU + manufacturer: LEDVANCE + model: PLUG COMPACT EU EM T + deviceProfileName: switch-power-energy + - id: "LEDVANCE/PLUG EU EM T" + deviceLabel: SMART ZIGBEE PLUG EU EM T + manufacturer: LEDVANCE + model: PLUG EU EM T + deviceProfileName: switch-power-energy - id: "OSRAM/LIGHTIFY Edge-lit flushmount" deviceLabel: SYLVANIA Light manufacturer: OSRAM @@ -2085,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 @@ -2200,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 @@ -2384,12 +2404,22 @@ zigbeeManufacturer: manufacturer: LAISIAO model: yuba deviceProfileName: switch-smart-bath-heater-laisiao + - id: "LAISIAO/DG60GCM-04-2904W" + deviceLabel: Laisiao Bathroom Heater + manufacturer: LAISIAO + model: DG60GCM-04-2904W + deviceProfileName: switch-smart-bath-heater-laisiao # NodOn - id: "NodOn/SIN-4-1-20" deviceLabel: Zigbee Multifunction Relay Switch manufacturer: NodOn model: SIN-4-1-20 deviceProfileName: basic-switch + - id: "NodOn/SIN-4-1-20-PRO" + deviceLabel: Zigbee Multifunction Relay Switch PRO + manufacturer: NodOn + model: SIN-4-1-20-PRO + deviceProfileName: basic-switch - id: "NodOn/SIN-4-2-20" deviceLabel: Zigbee ON/OFF Lighting Relay Switch manufacturer: NodOn @@ -2407,15 +2437,127 @@ zigbeeManufacturer: model: Y-K003-001 deviceProfileName: basic-switch - id: "JNL/Y-K001-001" - deviceLabel: Yanmi Switch (1 Way) + deviceLabel: Yanmi Switch (1 Way) manufacturer: JNL model: Y-K001-001 - deviceProfileName: basic-switch + deviceProfileName: basic-switch - id: "JNL/Y-K002-001" deviceLabel: Yanmi Switch (2 Way) 1 manufacturer: JNL model: Y-K002-001 deviceProfileName: basic-switch + #FIRSTLED + - id: "FIRSTLED/M4S4BAC" + deviceLabel: Mirror Series 4x4 1 + manufacturer: FIRSTLED + model: M4S4BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/M3S3BAC" + deviceLabel: Mirror Series 3x3 1 + manufacturer: FIRSTLED + model: M3S3BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/P3S3BAC" + deviceLabel: Punk Series 3x3 1 + manufacturer: FIRSTLED + model: P3S3BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/P2S2BAC" + deviceLabel: Punk Series 2x2 1 + manufacturer: FIRSTLED + model: P2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/P1S1BAC" + deviceLabel: Punk Series 1x1 1 + manufacturer: FIRSTLED + model: P1S1BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/MX4S2BAC" + deviceLabel: Matrix X Series 4x2 1 + manufacturer: FIRSTLED + model: MX4S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/MX3S2BAC" + deviceLabel: Matrix X Series 3x2 1 + manufacturer: FIRSTLED + model: MX3S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/MX2S2BAC" + deviceLabel: Matrix X Series 2x2 1 + manufacturer: FIRSTLED + model: MX2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/MX1S2BAC" + deviceLabel: Matrix X Series 1x2 1 + manufacturer: FIRSTLED + model: MX1S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/G3S3BAC" + deviceLabel: Guanzhi Series 3x3 1 + manufacturer: FIRSTLED + model: G3S3BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/G2S2BAC" + deviceLabel: Guanzhi Series 2x2 1 + manufacturer: FIRSTLED + model: G2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/G1S1BAC" + deviceLabel: Guanzhi Series 1x1 1 + manufacturer: FIRSTLED + model: G1S1BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DL4S4BAC" + deviceLabel: Dynamic Lite Series 4x4 1 + manufacturer: FIRSTLED + model: DL4S4BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DL3S3BAC" + deviceLabel: Dynamic Lite Series 3x3 1 + manufacturer: FIRSTLED + model: DL3S3BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DL2S2BAC" + deviceLabel: Dynamic Lite Series 2x2 1 + manufacturer: FIRSTLED + model: DL2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DL1S1BAC" + deviceLabel: Dynamic Lite Series 1x1 1 + manufacturer: FIRSTLED + model: DL1S1BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/M1S1BAC" + deviceLabel: Mirror Series 1x1 1 + manufacturer: FIRSTLED + model: M1S1BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/M2S2BAC" + deviceLabel: Mirror Series 2x2 1 + manufacturer: FIRSTLED + model: M2S2BAC + deviceProfileName: switch-light-restore-wireless + - id: "FIRSTLED/DC2DC12MiV1" + deviceLabel: Easy-Fit Driver + 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 @@ -2495,4 +2637,4 @@ zigbeeGeneric: - 0x0006 #OnOff - 0x0702 #Simple Metering - 0x0B04 #Electrical Measurment - deviceProfileName: switch-power \ No newline at end of file + deviceProfileName: switch-power diff --git a/drivers/SmartThings/zigbee-switch/profiles/abl-light-z-001-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/abl-light-z-001-bulb.yml index 8a0d62b1f6..28d233316c 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/abl-light-z-001-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/abl-light-z-001-bulb.yml @@ -6,8 +6,12 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-dual-relay-module-unified.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-dual-relay-module-unified.yml new file mode 100644 index 0000000000..b77d7eac0f --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-dual-relay-module-unified.yml @@ -0,0 +1,40 @@ +name: aqara-dual-relay-module-unified +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch + - id: interlock + capabilities: + - id: mode + version: 1 + categories: + - name: Switch + - id: devicemode + capabilities: + - id: mode + version: 1 + categories: + - name: Switch +preferences: + - preferenceId: stse.powerOffMemory + explicit: true + - preferenceId: stse.pulseIntervalSetting + explicit: true + - preferenceId: stse.switchType + explicit: true + - preferenceId: stse.changeToWirelessSwitch + explicit: true +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-Aqara_Dual_Relay_Module_T2 diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-led-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-led-bulb.yml index 65ee11beb0..c3d8f2f16d 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/aqara-led-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-led-bulb.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [2700, 6500] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-light.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-light.yml index 6c2da08393..07cc4b4904 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/aqara-light.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-light.yml @@ -10,8 +10,12 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/color-bulb.yml index fbe4243f6a..8395432142 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-bulb.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2000K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2000K-6500K.yml index fae09d20cb..43b1953caf 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2000K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2000K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2000, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-4000K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-4000K.yml index 0e542eff43..4a81aac14c 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-4000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-4000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 4000 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-5000K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-5000K.yml index e8495a5b6c..420f43c959 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-5000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-5000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 5000 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-6500K.yml index 985ec05a4f..221be95e5d 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2200K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2500K-6000K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2500K-6000K.yml index e6ffe1a46f..7715942435 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2500K-6000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2500K-6000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2500, 6000 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-5000K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-5000K.yml index 15677d1307..a1b8fa2b7c 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-5000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-5000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 5000 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-6500K.yml index b56cb5f84e..2cbe79b906 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb-2700K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb.yml index c30ba1d25f..e882f23098 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/color-temp-bulb.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml new file mode 100644 index 0000000000..551061d7b5 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/frient-io-output-switch.yml @@ -0,0 +1,25 @@ +name: frient-io-output-switch +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: refresh + version: 1 +preferences: + - title: "Output: On Time" + name: configOnTime + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 6553 + default: 0 + - title: "Output: Off Wait Time" + name: configOffWaitTime + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 6553 + default: 0 \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/profiles/ge-link-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/ge-link-bulb.yml index e0c09885c4..0381032825 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/ge-link-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/ge-link-bulb.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm30-sn.yml b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm30-sn.yml index ff670c0097..6a41cae14b 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm30-sn.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm30-sn.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: temperatureMeasurement version: 1 - id: relativeHumidityMeasurement diff --git a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm31-sn.yml b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm31-sn.yml index f39f8324eb..47368ca7e3 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm31-sn.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm31-sn.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: energyMeter diff --git a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm32-sn.yml b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm32-sn.yml index 746890a15a..fe3b3de11b 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm32-sn.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/inovelli-vzm32-sn.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: motionSensor version: 1 - id: illuminanceMeasurement diff --git a/drivers/SmartThings/zigbee-switch/profiles/light-color-temp-time-restore.yml b/drivers/SmartThings/zigbee-switch/profiles/light-color-temp-time-restore.yml new file mode 100644 index 0000000000..45fd965a6c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/light-color-temp-time-restore.yml @@ -0,0 +1,74 @@ +name: light-color-temp-time-restore +components: + - id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 + - id: colorTemperature + version: 1 + - id: statelessColorTemperatureStep + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +preferences: + - title: "上电状态(power-on state/파워온 상태)" + name: powerOnMode + description: "上电状态(power-on state/파워온 상태)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "恢复记忆状态(restore/복원)" + default: 0 + - title: "驱动电流(drive current/구동 전류)" + name: ledDriveCurrent + description: "驱动电流(drive current/구동 전류)/50~333mA" + required: false + preferenceType: integer + definition: + minimum: 50 + maximum: 333 + default: 270 + - title: "亮度时间(dimmingTransitionTime/밝기 전환 시간)" + name: dimTransitionTime + description: "亮度过渡时间(dimming transition time/밝기 전환 시간)1000~10000ms" + required: false + preferenceType: integer + definition: + minimum: 1000 + maximum: 10000 + default: 1000 + - title: "色温时间(CCTTransitionTime/색온 전환 시간)" + name: colorTempTransitionTime + description: "色温过渡时间(CCT transition time/색온 전환 시간)1000~10000ms" + required: false + preferenceType: integer + definition: + minimum: 1000 + maximum: 10000 + default: 1000 + - title: "输出模式(output mode/출력 모드)" + name: outputMode + description: "输出模式(output mode/출력 모드)" + required: false + preferenceType: enumeration + definition: + options: + 0: "双路CW(dual CW/듀얼 CW)" + 1: "双路WC(dual WC/듀얼 WC)" + 2: "单路(single channel/단일 채널)" + default: 0 diff --git a/drivers/SmartThings/zigbee-switch/profiles/light-level-power-energy.yml b/drivers/SmartThings/zigbee-switch/profiles/light-level-power-energy.yml index b45fc5e0e8..ac516c23c0 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/light-level-power-energy.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/light-level-power-energy.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: energyMeter diff --git a/drivers/SmartThings/zigbee-switch/profiles/light-level-power.yml b/drivers/SmartThings/zigbee-switch/profiles/light-level-power.yml index 6eca96ab18..a07047269b 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/light-level-power.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/light-level-power.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-intensity.yml b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-intensity.yml index 0d8688cb6b..dc5e6e0ca2 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-intensity.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-intensity.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-motion-sensor.yml b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-motion-sensor.yml index 248bd66e7f..f344f32a34 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-motion-sensor.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-motion-sensor.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: motionSensor version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-no-firmware-update.yml b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-no-firmware-update.yml index a25ef4aa2c..f5ef30908f 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/on-off-level-no-firmware-update.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/on-off-level-no-firmware-update.yml @@ -6,6 +6,8 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/zigbee-switch/profiles/on-off-level.yml b/drivers/SmartThings/zigbee-switch/profiles/on-off-level.yml index 350c51c722..67f27f7289 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/on-off-level.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/on-off-level.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/plug-level-power.yml b/drivers/SmartThings/zigbee-switch/profiles/plug-level-power.yml index d6ac987d50..a234f15409 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/plug-level-power.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/plug-level-power.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-1800K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-1800K-6500K.yml index c95d6c4b16..6f6a78a4b5 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-1800K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-1800K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 1800, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2000K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2000K-6500K.yml index 89af9a7f94..bf7f81832d 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2000K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2000K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2000, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-4000K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-4000K.yml index 2e17ba527d..b1c7d3e379 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-4000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-4000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 4000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-5000K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-5000K.yml index d83b671f12..9032ba0fe0 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-5000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-5000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 5000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-6500K.yml index 0d334ca62e..1a13390cf5 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2200K-6500K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2200, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2500K-6000K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2500K-6000K.yml index 3bd54e3a59..c74ba232c0 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2500K-6000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2500K-6000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2500, 6000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-5000K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-5000K.yml index 740a002b83..466a34c06a 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-5000K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-5000K.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 5000 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-6500K.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-6500K.yml index 8878a04a99..dcab0e8224 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-6500K.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb-2700K-6500K.yml @@ -6,12 +6,16 @@ components: version: 1 - id: switchLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb.yml b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb.yml index b863f9e587..57f566bdfb 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/rgbw-bulb.yml @@ -10,12 +10,16 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: colorTemperature version: 1 config: values: - key: "colorTemperature.value" range: [ 2700, 6500 ] + - id: statelessColorTemperatureStep + version: 1 - id: colorControl version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml new file mode 100644 index 0000000000..c6120b2561 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-4inputs-2outputs.yml @@ -0,0 +1,107 @@ +name: switch-4inputs-2outputs +components: + - id: main + capabilities: + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch + - id: input1 + label: "Input 1" + capabilities: + - id: switch + version: 1 + - id: input2 + label: "Input 2" + capabilities: + - id: switch + version: 1 + - id: input3 + label: "Input 3" + capabilities: + - id: switch + version: 1 + - id: input4 + label: "Input 4" + capabilities: + - id: switch + version: 1 +preferences: + # Input 1 + - title: "Input 1: Reverse Polarity" + name: reversePolarity1 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 1: Control Output 1" + name: controlOutput11 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 1: Control Output 2" + name: controlOutput21 + required: true + preferenceType: boolean + definition: + default: false + # Input 2 + - title: "Input 2: Reverse Polarity" + name: reversePolarity2 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 2: Control Output 1" + name: controlOutput12 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 2: Control Output 2" + name: controlOutput22 + required: true + preferenceType: boolean + definition: + default: false + # Input 3 + - title: "Input 3: Reverse Polarity" + name: reversePolarity3 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 3: Control Output 1" + name: controlOutput13 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 3: Control Output 2" + name: controlOutput23 + required: true + preferenceType: boolean + definition: + default: false + # Input 4 + - title: "Input 4: Reverse Polarity" + name: reversePolarity4 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 4: Control Output 1" + name: controlOutput14 + required: true + preferenceType: boolean + definition: + default: false + - title: "Input 4: Control Output 2" + name: controlOutput24 + required: true + preferenceType: boolean + definition: + default: false \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml new file mode 100644 index 0000000000..f98b552b87 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-button-light-restore-wireless.yml @@ -0,0 +1,39 @@ +name: switch-button-light-restore-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +preferences: + - title: "背光灯(backlight/백라이트)" + name: backlight + description: "背光灯(backlight/백라이트)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "人体接近感应(proximity detection/근접 감지)" + default: 2 + - title: "开关上电状态(relay powerOn state)" + name: powerOnStatus + description: "开关上电状态(relay powerOn state/릴레이 초기 동작 상태)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "恢复记忆状态(restore/복원)" + default: 2 + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml new file mode 100644 index 0000000000..c48290e1a8 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-button-wireless.yml @@ -0,0 +1,17 @@ +name: switch-button-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +preferences: + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-dimmer-power-energy.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-dimmer-power-energy.yml index 4507ab5282..623156bf6c 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/switch-dimmer-power-energy.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-dimmer-power-energy.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: energyMeter diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-level-power.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-level-power.yml index 2042471bf3..43b2b26581 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/switch-level-power.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-level-power.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: powerMeter version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-level.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-level.yml index 96166ef0d9..abcaba3e21 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/switch-level.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-level.yml @@ -10,6 +10,8 @@ components: values: - key: "level.value" range: [1, 100] + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml new file mode 100644 index 0000000000..b2e002d690 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-light-restore-wireless.yml @@ -0,0 +1,37 @@ +name: switch-light-restore-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - title: "背光灯(backlight/백라이트)" + name: backlight + description: "背光灯(backlight/백라이트)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "人体接近感应(proximity detection/근접 감지)" + default: 2 + - title: "开关上电状态(relay powerOn state)" + name: powerOnStatus + description: "开关上电状态(relay powerOn state/릴레이 초기 동작 상태)" + required: false + preferenceType: enumeration + definition: + options: + 0: "关闭(off/닫다)" + 1: "打开(on/열다)" + 2: "恢复记忆状态(restore/복원)" + default: 2 + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml b/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml new file mode 100644 index 0000000000..cb4be470f9 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/switch-wireless.yml @@ -0,0 +1,15 @@ +name: switch-wireless +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +preferences: + - preferenceId: stse.changeToWirelessSwitch + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-light/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara-light/init.lua index 06d4bf1cc7..2192716b21 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara-light/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara-light/init.lua @@ -54,18 +54,9 @@ local function set_level_handler(driver, device, cmd) device:send(Level.commands.MoveToLevelWithOnOff(device, level, dimming_rate)) end -local function init(self, device) - local value = { minimum = 2700, maximum = 6000 } - if device:get_model() == "lumi.light.cwacn1" then - value.maximum = 6500 - end - emit_event_if_latest_state_missing(device, "main", capabilities.colorTemperature, capabilities.colorTemperature.colorTemperatureRange.NAME, capabilities.colorTemperature.colorTemperatureRange(value)) -end - local aqara_light_handler = { NAME = "Aqara Light Handler", lifecycle_handlers = { - init = init, added = device_added, doConfigure = do_configure }, diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua index e183f78266..c0eb27343e 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/can_handle.lua @@ -1,6 +1,8 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- Matches any device whose manufacturer/model is listed in aqara/fingerprints.lua, routing it to +-- the aqara sub-driver (and, in turn, its version / multi-switch sub-drivers). return function(opts, driver, device) local FINGERPRINTS = require("aqara.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua index 63f94e2be3..3fc2fbc3e3 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/fingerprints.lua @@ -2,21 +2,22 @@ -- Licensed under the Apache License, Version 2.0 return { - { mfr = "LUMI", model = "lumi.plug.maeu01" }, - { mfr = "LUMI", model = "lumi.plug.macn01" }, - { mfr = "LUMI", model = "lumi.switch.n0agl1" }, - { mfr = "LUMI", model = "lumi.switch.l0agl1" }, - { mfr = "LUMI", model = "lumi.switch.n0acn2" }, - { mfr = "LUMI", model = "lumi.switch.n1acn1" }, - { mfr = "LUMI", model = "lumi.switch.n2acn1" }, - { mfr = "LUMI", model = "lumi.switch.n3acn1" }, - { mfr = "LUMI", model = "lumi.switch.b1laus01" }, - { mfr = "LUMI", model = "lumi.switch.b2laus01" }, - { mfr = "LUMI", model = "lumi.switch.n1aeu1" }, - { mfr = "LUMI", model = "lumi.switch.n2aeu1" }, - { mfr = "LUMI", model = "lumi.switch.l1aeu1" }, - { mfr = "LUMI", model = "lumi.switch.l2aeu1" }, - { mfr = "LUMI", model = "lumi.switch.b1nacn01" }, - { mfr = "LUMI", model = "lumi.switch.b2nacn01" }, - { mfr = "LUMI", model = "lumi.switch.b3n01" } - } + { mfr = "LUMI", model = "lumi.plug.maeu01" }, + { mfr = "LUMI", model = "lumi.plug.macn01" }, + { mfr = "LUMI", model = "lumi.switch.n0agl1" }, + { mfr = "LUMI", model = "lumi.switch.l0agl1" }, + { mfr = "LUMI", model = "lumi.switch.n0acn2" }, + { mfr = "LUMI", model = "lumi.switch.n1acn1" }, + { mfr = "LUMI", model = "lumi.switch.n2acn1" }, + { mfr = "LUMI", model = "lumi.switch.n3acn1" }, + { mfr = "LUMI", model = "lumi.switch.b1laus01" }, + { mfr = "LUMI", model = "lumi.switch.b2laus01" }, + { mfr = "LUMI", model = "lumi.switch.n1aeu1" }, + { mfr = "LUMI", model = "lumi.switch.n2aeu1" }, + { mfr = "LUMI", model = "lumi.switch.l1aeu1" }, + { mfr = "LUMI", model = "lumi.switch.l2aeu1" }, + { mfr = "LUMI", model = "lumi.switch.b1nacn01" }, + { mfr = "LUMI", model = "lumi.switch.b2nacn01" }, + { mfr = "LUMI", model = "lumi.switch.b3n01" }, + { mfr = "Aqara", model = "lumi.switch.acn047" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara/init.lua index 0c64b594fe..d3666a3dcb 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/init.lua @@ -23,9 +23,31 @@ local CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID = 0x0200 local RESTORE_TURN_OFF_INDICATOR_LIGHT_ATTRIBUTE_ID = 0x0203 local MAX_POWER_ATTRIBUTE_ID = 0x020B local ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID = 0x000A - +-- Aqara private cluster (0xFCC0) attributes used by the Dual Relay Module T2 (lumi.switch.acn047) +local DEVICE_MODE_ATTRIBUTE_ID = 0x0289 -- relay working mode (wet/dry contact, pulse), Uint8 0..3 +local INTERLOCK_ATTRIBUTE_ID = 0x02D0 -- interlock between the two relays, Boolean +local POWER_OFF_MEMORY_ATTRIBUTE_ID = 0x0517 -- power-off memory behavior, Uint8 (see powerOffMemory value_map) +local PULSE_INTERVAL_ATTRIBUTE_ID = 0x00EB -- pulse width in ms when running in pulse mode, Uint16 local LAST_REPORT_TIME = "LAST_REPORT_TIME" local PRIVATE_MODE = "PRIVATE_MODE" +-- "interlock" / "devicemode" are extra profile components on aqara-dual-relay-module-unified.yml. +-- The order of the SUPPORTED_* lists matches the raw device values (0-based), see the handlers below. +local COMPONENT_INTERLOCK = "interlock" +local SUPPORTED_INTERLOCK = { "normal", "interlock" } +local COMPONENT_DEVICE_MODE = "devicemode" +-- Relay working modes mapped to their raw device values. dry_contact_open_pulse_mode (raw 2) is +-- intentionally not exposed; the remaining modes keep their original device values (on_off stays 3). +-- This mode is not supported by the Aqara app and cannot be entered by the device, so it is omitted. +local DEVICE_MODE_TO_VALUE = { + wet_contact_mode = 0, + dry_contact_closed_pulse_mode = 1, + dry_contact_on_off_mode = 3, +} +local DEVICE_MODE_FROM_VALUE = { + [0] = "wet_contact_mode", + [1] = "dry_contact_closed_pulse_mode", + [3] = "dry_contact_on_off_mode", +} local preference_map = { ["stse.restorePowerState"] = { @@ -39,7 +61,7 @@ local preference_map = { attribute_id = CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, mfg_code = MFG_CODE, data_type = data_types.Uint8, - value_map = { [true] = 0x00,[false] = 0x01 }, + value_map = { [true] = 0x00, [false] = 0x01 }, }, ["stse.maxPower"] = { cluster_id = PRIVATE_CLUSTER_ID, @@ -112,26 +134,71 @@ local preference_map = { data_type = data_types.Uint8, value_map = { rocker = 0x01, rebound = 0x02 }, }, + -- External switch wiring type (same attribute as stse.electricSwitchType, with an extra "nc" + -- option): rocker = maintained, rebound/button = momentary, nc = external switch ignored (normally closed). + ["stse.switchType"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + value_map = { rocker = 0x01, rebound = 0x02, nc = 0x00 }, + }, ["stse.turnOffIndicatorLight"] = { cluster_id = PRIVATE_CLUSTER_ID, attribute_id = RESTORE_TURN_OFF_INDICATOR_LIGHT_ATTRIBUTE_ID, mfg_code = MFG_CODE, data_type = data_types.Boolean, }, + ["stse.powerOffMemory"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = POWER_OFF_MEMORY_ATTRIBUTE_ID, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + value_map = { restore = 0x01, on = 0x00, off = 0x02, reverse = 0x03 }, + }, + ["stse.pulseIntervalSetting"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = PULSE_INTERVAL_ATTRIBUTE_ID, + mfg_code = MFG_CODE, + data_type = data_types.Uint16, + coerce_number = true, + } } - +-- Handles reports of the private-mode attribute. Caches the current private-mode state and, when the +-- device is not yet in private mode, forces it into private mode and configures energy reporting. +-- acn047 is excluded from being forced into private mode (it stays on standard clusters). local function private_mode_handler(driver, device, value, zb_rx) device:set_field(PRIVATE_MODE, value.value, { persist = true }) if value.value ~= 1 then - device:send(cluster_base.write_manufacturer_specific_attribute(device, - PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) -- private + if device:get_model() ~= "lumi.switch.acn047" then + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) -- private + end device:send(SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(device, 900, 3600, 1)) -- minimal interval : 15min device:set_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY, 10, { persist = true }) device:set_field(constants.SIMPLE_METERING_DIVISOR_KEY, 1000, { persist = true }) end end +-- Reflect the device's interlock state onto the "interlock" component. +-- value.value is a Boolean; +1 converts the 0/1 state into a Lua (1-based) list index. +local function interlock_switch_handler(driver, device, value, zb_rx) + local component = device.profile.components[COMPONENT_INTERLOCK] + if component == nil then return end + local cur_state = 0 + if value.value then cur_state = 1 end + device:emit_component_event(component, capabilities.mode.mode(SUPPORTED_INTERLOCK[cur_state + 1])) +end +-- Reflect the relay working mode onto the "devicemode" component. +-- value.value is the raw device value; only emit for modes we expose (open_pulse is ignored). +local function device_mode_handler(driver, device, value, zb_rx) + local component = device.profile.components[COMPONENT_DEVICE_MODE] + if component == nil then return end + local mode = DEVICE_MODE_FROM_VALUE[value.value] + if mode ~= nil then + device:emit_component_event(component, capabilities.mode.mode(mode)) + end +end local function wireless_switch_handler(driver, device, value, zb_rx) if value.value == 1 then @@ -151,7 +218,7 @@ local function energy_meter_power_consumption_report(driver, device, value, zb_r if raw_value < offset then --- somehow our value has gone below the offset, so we'll reset the offset, since the device seems to have offset = 0 - device:set_field(constants.ENERGY_METER_OFFSET, offset, {persist = true}) + device:set_field(constants.ENERGY_METER_OFFSET, offset, { persist = true }) end device:emit_event(capabilities.energyMeter.energy({ value = raw_value - offset, unit = "Wh" })) @@ -166,8 +233,7 @@ local function energy_meter_power_consumption_report(driver, device, value, zb_r -- power consumption report local delta_energy = 0.0 - local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, - capabilities.powerConsumptionReport.powerConsumption.NAME) + local current_power_consumption = device:get_latest_state("main", capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) if current_power_consumption ~= nil then delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) end @@ -184,14 +250,40 @@ local function power_meter_handler(driver, device, value, zb_rx) device:emit_event(capabilities.powerMeter.power({ value = raw_value, unit = "W" })) end +-- setMode command handler shared by the "interlock" and "devicemode" components. +-- The target component is used to decide which private-cluster attribute to write. +local function mode_handler(driver, device, command) + if command.component == COMPONENT_INTERLOCK then + -- interlock attribute is a Boolean: true = relays interlocked, false = independent + local interlock_mode = false + if command.args.mode == SUPPORTED_INTERLOCK[2] then interlock_mode = true end + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, INTERLOCK_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, interlock_mode)) + elseif command.component == COMPONENT_DEVICE_MODE then + -- map the selected mode string to its raw device value + local device_mode = DEVICE_MODE_TO_VALUE[command.args.mode] + if device_mode ~= nil then + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, DEVICE_MODE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, device_mode)) + end + end +end +-- Read back switch state, power/energy (standard clusters) and, when present, the interlock and +-- device-mode private attributes. local function do_refresh(self, device) device:send(OnOff.attributes.OnOff:read(device)) if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then device:send(ElectricalMeasurement.attributes.ActivePower:read(device)) device:send(SimpleMetering.attributes.CurrentSummationDelivered:read(device)) end + if device.profile.components[COMPONENT_INTERLOCK] then + device:send(cluster_base.read_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, INTERLOCK_ATTRIBUTE_ID, MFG_CODE)) + end + if device.profile.components[COMPONENT_DEVICE_MODE] then + device:send(cluster_base.read_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, DEVICE_MODE_ATTRIBUTE_ID, MFG_CODE)) + end end +-- On preference change, write any preference whose value changed to its mapped private-cluster +-- attribute (see preference_map). value_map translates enum strings; value_type coerces numbers. local function device_info_changed(driver, device, event, args) local preferences = device.preferences local old_preferences = args.old_st_store.preferences @@ -203,28 +295,36 @@ local function device_info_changed(driver, device, event, args) if attr.value_map ~= nil then value = attr.value_map[value] end - device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, - attr.mfg_code, attr.data_type, value)) + -- numeric preferences (e.g. pulseInterval) are coerced to a Lua number before being written + if attr.coerce_number ~= nil and attr.coerce_number then + value = tonumber(value) + end + device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, attr.mfg_code, attr.data_type, value)) end end end end +-- Standard configuration: bind/report standard clusters, read the private-mode attribute, clear any +-- groups (required by these devices) and refresh current state. local function do_configure(self, device) device:configure() - device:send(cluster_base.read_manufacturer_specific_attribute(device, - PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE)) device:send(Groups.server.commands.RemoveAllGroups(device)) -- required do_refresh(self, device) end +-- On add, advertise supported button values and restore the last known power/energy (so the values +-- are not blanked to 0 on re-add). local function device_added(driver, device) if (device:supports_capability_by_id(capabilities.button.ID)) then device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } })) end if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then - device:emit_event(capabilities.powerMeter.power({ value = 0.0, unit = "W" })) - device:emit_event(capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + local lastPower = device:get_latest_state("main", capabilities.powerMeter.ID, capabilities.powerMeter.power.NAME) or 0.0 + local lastEnergy = device:get_latest_state("main", capabilities.energyMeter.ID, capabilities.energyMeter.energy.NAME) or 0.0 + device:emit_event(capabilities.powerMeter.power({ value = lastPower, unit = "W" })) + device:emit_event(capabilities.energyMeter.energy({ value = lastEnergy, unit = "Wh" })) end end @@ -236,6 +336,9 @@ local aqara_switch_handler = { infoChanged = device_info_changed }, capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = mode_handler + }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } @@ -252,7 +355,9 @@ local aqara_switch_handler = { [WIRELESS_SWITCH_ATTRIBUTE_ID] = wireless_switch_handler }, [PRIVATE_CLUSTER_ID] = { - [PRIVATE_ATTRIBUTE_ID] = private_mode_handler + [PRIVATE_ATTRIBUTE_ID] = private_mode_handler, + [INTERLOCK_ATTRIBUTE_ID] = interlock_switch_handler, + [DEVICE_MODE_ATTRIBUTE_ID] = device_mode_handler } } }, diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua index 1524296cff..16d61cfbbf 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/can_handle.lua @@ -1,6 +1,8 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- Matches multi-endpoint Aqara switches/modules listed in aqara/multi-switch/fingerprints.lua +-- (devices that create child devices for their extra switch endpoints). return function(opts, driver, device) local FINGERPRINTS = require("aqara.multi-switch.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua index 71ed1b26b3..22887a7e88 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/fingerprints.lua @@ -2,13 +2,14 @@ -- Licensed under the Apache License, Version 2.0 return { - { mfr = "LUMI", model = "lumi.switch.n1acn1", children = 1, child_profile = "" }, - { mfr = "LUMI", model = "lumi.switch.n2acn1", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.n3acn1", children = 3, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.b1laus01", children = 1, child_profile = "" }, - { mfr = "LUMI", model = "lumi.switch.b2laus01", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.l2aeu1", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.n2aeu1", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.b2nacn01", children = 2, child_profile = "aqara-switch-child" }, - { mfr = "LUMI", model = "lumi.switch.b3n01", children = 3, child_profile = "aqara-switch-child" } + { mfr = "LUMI", model = "lumi.switch.n1acn1", children = 1, child_profile = "" }, + { mfr = "LUMI", model = "lumi.switch.n2acn1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.n3acn1", children = 3, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b1laus01", children = 1, child_profile = "" }, + { mfr = "LUMI", model = "lumi.switch.b2laus01", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.l2aeu1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.n2aeu1", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b2nacn01", children = 2, child_profile = "aqara-switch-child" }, + { mfr = "LUMI", model = "lumi.switch.b3n01", children = 3, child_profile = "aqara-switch-child" }, + { mfr = "Aqara", model = "lumi.switch.acn047", children = 2, child_profile = "aqara-switch-child" } } diff --git a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua index 9280a30d0d..07022c0b67 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara/multi-switch/init.lua @@ -11,8 +11,13 @@ local switch_utils = require "switch_utils" local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 local MFG_CODE = 0x115F +local COMPONENT_INTERLOCK = "interlock" +local SUPPORTED_INTERLOCK = { "normal", "interlock" } +local COMPONENT_DEVICE_MODE = "devicemode" +local SUPPORTED_DEVICE_MODE = { "wet_contact_mode", "dry_contact_closed_pulse_mode", "dry_contact_on_off_mode" } local FINGERPRINTS = require("aqara.multi-switch.fingerprints") +-- Number of switch endpoints (parent + children) for this model, from the fingerprint table. local function get_children_amount(device) for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_model() == fingerprint.model then @@ -21,6 +26,7 @@ local function get_children_amount(device) end end +-- Profile name to assign to the created child devices for this model. local function get_child_profile_name(device) for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_model() == fingerprint.model then @@ -37,6 +43,7 @@ local function find_child(parent, ep_id) return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) end +-- Create one EDGE_CHILD device per extra switch endpoint and initialize parent-only state. local function device_added(driver, device) -- Only create children for the actual Zigbee device and not the children if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then @@ -60,22 +67,36 @@ local function device_added(driver, device) end -- for wireless button - device:emit_event(capabilities.button.numberOfButtons({ value = children_amount }, - { visibility = { displayed = false } })) - device:emit_event(capabilities.powerMeter.power({ value = 0.0, unit = "W" })) - device:emit_event(capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) + device:emit_event(capabilities.button.numberOfButtons({ value = children_amount }, { visibility = { displayed = false } })) - device:send(cluster_base.write_manufacturer_specific_attribute(device, - PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) -- private + -- report the static supported modes for the interlock / devicemode components (when present) + if device.profile.components[COMPONENT_INTERLOCK] then + device:emit_component_event(device.profile.components[COMPONENT_INTERLOCK], capabilities.mode.supportedModes(SUPPORTED_INTERLOCK, { visibility = { displayed = false } })) + end + if device.profile.components[COMPONENT_DEVICE_MODE] then + device:emit_component_event(device.profile.components[COMPONENT_DEVICE_MODE], capabilities.mode.supportedModes(SUPPORTED_DEVICE_MODE, { visibility = { displayed = false } })) + end + + -- acn047 (Dual Relay Module T2) must not be forced into Aqara private mode; all other + -- multi-switch models are switched into private mode here. + if device:get_model() ~= "lumi.switch.acn047" then + device:send(cluster_base.write_manufacturer_specific_attribute(device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01)) -- private + end elseif device.network_type == "DEVICE_EDGE_CHILD" then - device:emit_event(capabilities.button.numberOfButtons({ value = 1 }, - { visibility = { displayed = false } })) + device:emit_event(capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } })) + end + device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } })) + switch_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({ state_change = false })) + -- restore the last known power/energy (instead of resetting to 0) so values survive re-adds + if (device:supports_capability_by_id(capabilities.powerMeter.ID)) then + local lastPower = device:get_latest_state("main", capabilities.powerMeter.ID, capabilities.powerMeter.power.NAME) or 0.0 + local lastEnergy = device:get_latest_state("main", capabilities.energyMeter.ID, capabilities.energyMeter.energy.NAME) or 0.0 + device:emit_event(capabilities.powerMeter.power({ value = lastPower, unit = "W" })) + device:emit_event(capabilities.energyMeter.energy({ value = lastEnergy, unit = "Wh" })) end - device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, - { visibility = { displayed = false } })) - switch_utils.emit_event_if_latest_state_missing(device, "main", capabilities.button, capabilities.button.button.NAME, capabilities.button.button.pushed({state_change = false})) end +-- Register the endpoint->child routing function so reports from child endpoints reach the children. local function device_init(self, device) -- for multiple switch if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then @@ -86,7 +107,7 @@ end local aqara_multi_switch_handler = { NAME = "Aqara Multi Switch Handler", lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(device_init), + init = configurations.reconfig_wrapper(device_init), added = device_added }, can_handle = require("aqara.multi-switch.can_handle"), diff --git a/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/can_handle.lua new file mode 100644 index 0000000000..56cd729659 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +return function(opts, driver, device) + if device:supports_capability(capabilities.colorTemperature) then + local subdriver = require("color_temp_range_handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/init.lua b/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/init.lua new file mode 100644 index 0000000000..84185557af --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/color_temp_range_handlers/init.lua @@ -0,0 +1,75 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local switch_utils = require "switch_utils" + +-- These values are a "sanity check" to ensure that max/min values we are getting are reasonable +local COLOR_TEMPERATURE_MIRED_MAX = 1000 -- 1000 Kelvin +local COLOR_TEMPERATURE_MIRED_MIN = 67 -- 15000 Kelvin + +local function color_temp_min_mireds_handler(driver, device, value, zb_rx) + -- if mired value is nil or outside of sane bounds, log and ignore. Else, save value + local min_mired_bound = value.value + if min_mired_bound == nil then + return + elseif (min_mired_bound < COLOR_TEMPERATURE_MIRED_MIN or min_mired_bound > COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", min_mired_bound, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) + return + end + device:set_field(switch_utils.MIRED_MIN_BOUND, min_mired_bound, {persist = true}) + + -- if we have already received a valid max mired bound, emit a colorTemperatureRange event + local max_mired_bound = device:get_field(switch_utils.MIRED_MAX_BOUND) + if max_mired_bound == nil then + return + elseif min_mired_bound < max_mired_bound then + local endpoint = zb_rx.address_header.src_endpoint.value + local max_kelvin_bound = switch_utils.convert_mired_to_kelvin(min_mired_bound) + local min_kelvin_bound = switch_utils.convert_mired_to_kelvin(max_mired_bound) + device:emit_event_for_endpoint(endpoint, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min_kelvin_bound, maximum = max_kelvin_bound}})) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a max color temperature %d Mireds that is not higher than the reported min color temperature %d Mireds", max_mired_bound, min_mired_bound)) + end +end + +local function color_temp_max_mireds_handler(driver, device, value, zb_rx) + -- if mired value is nil or outside of sane bounds, log and ignore. Else, save value + local max_mired_bound = value.value + if max_mired_bound == nil then + return + elseif (max_mired_bound < COLOR_TEMPERATURE_MIRED_MIN or max_mired_bound > COLOR_TEMPERATURE_MIRED_MAX) then + device.log.warn_with({hub_logs = true}, string.format("Device reported a color temperature %d mired outside of sane range of %.2f-%.2f", max_mired_bound, COLOR_TEMPERATURE_MIRED_MIN, COLOR_TEMPERATURE_MIRED_MAX)) + return + end + device:set_field(switch_utils.MIRED_MAX_BOUND, max_mired_bound, {persist = true}) + + -- if we have already received a valid min mired bound, emit a colorTemperatureRange event + local min_mired_bound = device:get_field(switch_utils.MIRED_MIN_BOUND) + if min_mired_bound == nil then + return + elseif max_mired_bound > min_mired_bound then + local endpoint = zb_rx.address_header.src_endpoint.value + local max_kelvin_bound = switch_utils.convert_mired_to_kelvin(min_mired_bound) + local min_kelvin_bound = switch_utils.convert_mired_to_kelvin(max_mired_bound) + device:emit_event_for_endpoint(endpoint, capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = min_kelvin_bound, maximum = max_kelvin_bound}})) + else + device.log.warn_with({hub_logs = true}, string.format("Device reported a min color temperature %d Mireds that is not lower than the reported max color temperature %d Mireds", min_mired_bound, max_mired_bound)) + end +end + +local color_temp_range_handlers = { + NAME = "Color temp range handlers", + zigbee_handlers = { + attr = { + [clusters.ColorControl.ID] = { + [clusters.ColorControl.attributes.ColorTempPhysicalMinMireds.ID] = color_temp_min_mireds_handler, + [clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds.ID] = color_temp_max_mireds_handler + } + } + }, + can_handle = require("color_temp_range_handlers.can_handle") +} + +return color_temp_range_handlers diff --git a/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua b/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua index 9910dde229..d49d10e9c5 100644 --- a/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua +++ b/drivers/SmartThings/zigbee-switch/src/configurations/devices.lua @@ -7,7 +7,10 @@ local IASZone = clusters.IASZone local ElectricalMeasurement = clusters.ElectricalMeasurement local SimpleMetering = clusters.SimpleMetering local Alarms = clusters.Alarms +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff local constants = require "st.zigbee.constants" +local data_types = require "st.zigbee.data_types" local devices = { IKEA_RGB_BULB = { @@ -110,6 +113,64 @@ local devices = { }, } }, + FRIENT_IO_MODULE = { + FINGERPRINTS = { + { mfr = "frient A/S", model = "IOMZB-110" } + }, + CONFIGURATION = { + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OnTime.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 1, + data_type = OnOff.attributes.OnOff.base_type, + configurable = true, + monitored = true + }, + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OffWaitTime.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 1, + data_type = OnOff.attributes.OffWaitTime.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.PresentValue.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = BasicInput.attributes.PresentValue.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = BasicInput.attributes.Polarity.ID, + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = BasicInput.attributes.Polarity.base_type, + configurable = true, + monitored = true + }, + { + cluster = BasicInput.ID, + attribute = 0x8000, -- IASActivation + minimum_interval = 10, + maximum_interval = 600, + reportable_change = 0, + data_type = data_types.Uint16, + mfg_code = 0x1015, + configurable = true, + monitored = true + } + } + } } return devices \ No newline at end of file diff --git a/drivers/SmartThings/zigbee-switch/src/configurations/init.lua b/drivers/SmartThings/zigbee-switch/src/configurations/init.lua index 01f42abf91..a5e55b3953 100644 --- a/drivers/SmartThings/zigbee-switch/src/configurations/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/configurations/init.lua @@ -84,7 +84,7 @@ configurations.handle_reporting_config_response = function(driver, device, zb_me end end -configurations.power_reconfig_wrapper = function(orig_function) +configurations.reconfig_wrapper = function(orig_function) local new_init = function(driver, device) local config_version = device:get_field(CONFIGURATION_VERSION_KEY) if config_version == nil or config_version < driver.current_config_version then @@ -92,6 +92,18 @@ configurations.power_reconfig_wrapper = function(orig_function) driver._reconfig_timer = driver:call_with_delay(5*60, configurations.check_and_reconfig_devices, "reconfig_power_devices") end end + + local capabilities = require "st.capabilities" + for id, _ in pairs(device.profile.components) do + if device:supports_capability(capabilities.colorTemperature, id) and + device:get_latest_state(id, capabilities.colorTemperature.ID, capabilities.colorTemperature.colorTemperatureRange.NAME) == nil then + local clusters = require "st.zigbee.zcl.clusters" + driver:call_with_delay(5*60, function() + device:send_to_component(id, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:read(device)) + device:send_to_component(id, clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:read(device)) + end) + end + end orig_function(driver, device) end return new_init diff --git a/drivers/SmartThings/zigbee-switch/src/ezex/init.lua b/drivers/SmartThings/zigbee-switch/src/ezex/init.lua index 6c2d9e45e3..ff1b2deb7b 100644 --- a/drivers/SmartThings/zigbee-switch/src/ezex/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/ezex/init.lua @@ -12,7 +12,7 @@ end local ezex_switch_handler = { NAME = "ezex switch handler", lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(do_init) + init = configurations.reconfig_wrapper(do_init) }, can_handle = require("ezex.can_handle"), } diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua new file mode 100644 index 0000000000..5ad2d32a4b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local FINGERPRINTS = require("firstled-io.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("firstled-io") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua new file mode 100644 index 0000000000..1b5722fa7c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/fingerprints.lua @@ -0,0 +1,26 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--The number of children determines the number of sub-devices to be created. Each sub-device has the capability to switch between a switch and a button. +--The number of buttons determines how many buttons devices will be created. +--The driver supports a series of device combinations, such as 4+4, 3+3, 2+2, 4+0, etc., of switch and button type products. +return { + { mfr = "FIRSTLED", model = "M4S4BAC", children = 4, buttons = 4, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "M3S3BAC", children = 3, buttons = 3, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "P3S3BAC", children = 3, buttons = 3, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "P2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "P1S1BAC", children = 1, buttons = 1, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "MX4S2BAC", children = 4, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "MX3S2BAC", children = 3, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "MX2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "MX1S2BAC", children = 1, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "G3S3BAC", children = 3, buttons = 3, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "G2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "G1S1BAC", children = 1, buttons = 1, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "DL4S4BAC", children = 4, buttons = 4, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "DL3S3BAC", children = 3, buttons = 3, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "DL2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "DL1S1BAC", children = 1, buttons = 1, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "M1S1BAC", children = 1, buttons = 1, child_profile = "switch-wireless" }, + { mfr = "FIRSTLED", model = "M2S2BAC", children = 2, buttons = 2, child_profile = "switch-wireless" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua b/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua new file mode 100644 index 0000000000..6348f254b2 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-io/init.lua @@ -0,0 +1,209 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local device_lib = require "st.device" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local Scenes = zcl_clusters.Scenes +local PRIVATE_CLUSTER_ID = 0xFCCA +local MFG_CODE = 0x1235 +local FINGERPRINTS = require("firstled-io.fingerprints") + +--stse.changeToWirelessSwitch +--switch mode:The local switch and the app can control the relay.The button capability is not working. +--wirelessSwitch mode:The local switch does not control the relay. Once triggered, it will report to the system as "RecallScene",emit_event button.pushed. The relay can be controlled via the app. +local preference_map = { + ["backlight"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0000, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + }, + ["powerOnStatus"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0001, + mfg_code = MFG_CODE, + data_type = data_types.Uint8, + }, + ["stse.changeToWirelessSwitch"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0002, + mfg_code = MFG_CODE, + data_type = data_types.Boolean + } +} + +local function is_parent_device(device) + local parent = device:get_parent_device() + return parent == nil +end + +--If it is in the switch mode, only the switch will be displayed. If it is in the wirelessSwitch mode, both the switch and the button will be displayed. +local function toggle_button_visibility(device, show) + local is_parent = is_parent_device(device) + if is_parent then + if show then + device:try_update_metadata({profile = "switch-button-light-restore-wireless"}) + else + device:try_update_metadata({profile = "switch-light-restore-wireless"}) + end + else + if show then + device:try_update_metadata({profile = "switch-button-wireless"}) + else + device:try_update_metadata({profile = "switch-wireless"}) + end + end +end + +--When stse.changeToWirelessSwitch switching to the wirelessSwitch mode to listen for profile changes +local function listen_profile_button_transition(device, args) + local current_has = device:supports_capability(capabilities.button, "main") + + local old_has = false + if args and args.old_st_store and args.old_st_store.profile then + local old_main = args.old_st_store.profile.components.main + if old_main and old_main.capabilities then + old_has = old_main.capabilities["button"] ~= nil + end + end + --Capabilities button from non-existent to existing + if not old_has and current_has then + device:emit_event(capabilities.button.numberOfButtons({value = 1}, {visibility = {displayed = false}})) + device:emit_event(capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}})) + end +end + +local function device_info_changed(driver, device, event, args) + listen_profile_button_transition(device, args) + + local preferences = device.preferences + local old_preferences = args.old_st_store.preferences + if preferences ~= nil then + for id, attr in pairs(preference_map) do + local old_value = old_preferences[id] + local value = preferences[id] + if value ~= nil and value ~= old_value then + if attr.data_type == data_types.Uint8 then + value = tonumber(value) + end + device:send(cluster_base.write_manufacturer_specific_attribute(device, attr.cluster_id, attr.attribute_id, + attr.mfg_code, attr.data_type, value)) + --Switch to the corresponding profile based on stse.changeToWirelessSwitch + if id == "stse.changeToWirelessSwitch" then + toggle_button_visibility(device, value) + end + end + end + end +end + +local function get_children_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.children + end + end +end + +local function get_button_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.buttons + end + end +end + +local function get_child_profile_name(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.child_profile + end + end +end + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end + +--Create composite switches such as 4+4, 1+1, 4+0, 3+0 children+button +local function device_added(driver, device) + -- Create the corresponding number of child devices based on the value of fingerprint.children + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + local children_amount = get_children_amount(device) + if children_amount >= 2 then + for i = 2, children_amount, 1 do + if find_child(device, i) == nil then + local name = string.format("%s%d", string.sub(device.label, 0, -2), i) + local child_profile = get_child_profile_name(device) + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + -- Create the corresponding number of button devices based on the value of fingerprint.buttons + local button_amount = get_button_amount(device) + if button_amount >= 1 then + for i = children_amount + 1, children_amount + button_amount, 1 do + if find_child(device, i) == nil then + local name = string.format("%s%d", string.sub(device.label, 0, -2), i) + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = "button", + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name, + } + driver:try_create_device(metadata) + end + end + end + elseif device.network_type == "DEVICE_EDGE_CHILD" then + device:emit_event(capabilities.button.numberOfButtons({ value = 1 }, + { visibility = { displayed = false } })) + device:emit_event(capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } })) + end +end + +local function scenes_cluster_handler(driver, device, zb_rx) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, + capabilities.button.button.pushed({ state_change = true })) +end + +local function device_init(self, device) + -- for multiple switch + if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + device:set_find_child(find_child) + end +end + +local firstled_switch_handler = { + NAME = "FIRSTLED Switch Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = device_info_changed + }, + zigbee_handlers = { + cluster = { + [Scenes.ID] = { + [Scenes.server.commands.RecallScene.ID] = scenes_cluster_handler, + } + } + }, + can_handle = require("firstled-io.can_handle"), +} + +return firstled_switch_handler diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-light/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/firstled-light/can_handle.lua new file mode 100644 index 0000000000..6b02320ebf --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-light/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "firstled-light.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("firstled-light") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-light/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/firstled-light/fingerprints.lua new file mode 100644 index 0000000000..a406321a2c --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-light/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "FIRSTLED", model = "DC2DC12MiV1" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/firstled-light/init.lua b/drivers/SmartThings/zigbee-switch/src/firstled-light/init.lua new file mode 100644 index 0000000000..ab81b0f0cf --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/firstled-light/init.lua @@ -0,0 +1,63 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" + +local PRIVATE_CLUSTER_ID = 0xFC00 + +local preference_map = { + ["outputMode"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0000, + data_type = data_types.Uint8 + }, + ["powerOnMode"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0001, + data_type = data_types.Uint8 + }, + ["ledDriveCurrent"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0002, + data_type = data_types.Uint16 + }, + ["dimTransitionTime"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0003, + data_type = data_types.Uint16 + }, + ["colorTempTransitionTime"] = { + cluster_id = PRIVATE_CLUSTER_ID, + attribute_id = 0x0004, + data_type = data_types.Uint16 + } +} + +local function device_info_changed(driver, device, event, args) + local preferences = device.preferences + local old_preferences = args.old_st_store.preferences + if preferences ~= nil then + for id, attr in pairs(preference_map) do + local old_value = old_preferences[id] + local value = preferences[id] + if value ~= nil and value ~= old_value then + value = tonumber(value) + device:send(cluster_base.write_attribute(device, + data_types.ClusterId(attr.cluster_id), + data_types.AttributeId(attr.attribute_id), + data_types.validate_or_build_type(value, attr.data_type, "payload"))) + end + end + end +end + +local firstled_light_handlers = { + NAME = "firstled-light handlers", + lifecycle_handlers = { + infoChanged = device_info_changed + }, + can_handle = require("firstled-light.can_handle") +} + +return firstled_light_handlers diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua new file mode 100644 index 0000000000..4e1d465ee3 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Function to determine if the driver can handle this device +return function(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and device:get_model() == "IOMZB-110" then + local subdriver = require("frient-IO") + return true, subdriver + else + return false + end +end diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua new file mode 100644 index 0000000000..d7601ab535 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/init.lua @@ -0,0 +1,488 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Zigbee Spec Utils +local constants = require "st.zigbee.constants" +local messages = require "st.zigbee.messages" +local zdo_messages = require "st.zigbee.zdo" +local bind_request = require "st.zigbee.zdo.bind_request" +local unbind_request = require "frient-IO.unbind_request" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local zcl_global_commands = require "st.zigbee.zcl.global_commands" +local Status = require "st.zigbee.generated.types.ZclStatus" + +local clusters = require "st.zigbee.zcl.clusters" +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff +-- Capabilities +local capabilities = require "st.capabilities" +local Switch = capabilities.switch +local CHILD_OUTPUT_PROFILE = "frient-io-output-switch" +local utils = require "st.utils" + +local configurationMap = require "configurations" + +local COMPONENTS = { + INPUT_1 = "input1", + INPUT_2 = "input2", + INPUT_3 = "input3", + INPUT_4 = "input4", + OUTPUT_1 = "output1", + OUTPUT_2 = "output2" +} + +local ZIGBEE_ENDPOINTS = { + INPUT_1 = 0x70, + INPUT_2 = 0x71, + INPUT_3 = 0x72, + INPUT_4 = 0x73, + OUTPUT_1 = 0x74, + OUTPUT_2 = 0x75 +} + +local INPUT_CONFIGS = { + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_1, + reverse_pref = "reversePolarity1", + binds = { + { pref = "controlOutput11", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput21", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_2, + reverse_pref = "reversePolarity2", + binds = { + { pref = "controlOutput12", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput22", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_3, + reverse_pref = "reversePolarity3", + binds = { + { pref = "controlOutput13", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput23", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_4, + reverse_pref = "reversePolarity4", + binds = { + { pref = "controlOutput14", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1 }, + { pref = "controlOutput24", endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2 } + } + } +} + +local OUTPUT_INFO = { + ["1"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_1, key = "frient-io-output-1", label_suffix = "Output 1" }, + ["2"] = { endpoint = ZIGBEE_ENDPOINTS.OUTPUT_2, key = "frient-io-output-2", label_suffix = "Output 2" } +} + +local OUTPUT_BY_ENDPOINT, OUTPUT_BY_KEY = {}, {} +for suffix, info in pairs(OUTPUT_INFO) do + info.suffix = suffix + OUTPUT_BY_ENDPOINT[info.endpoint] = info + OUTPUT_BY_KEY[info.key] = info +end + +local ZIGBEE_MFG_CODES = { + Develco = 0x1015 +} + +local ZIGBEE_MFG_ATTRIBUTES = { + client = { + OnWithTimeOff_OnTime = { + ID = 0x8000, + data_type = data_types.Uint16 + }, + OnWithTimeOff_OffWaitTime = { + ID = 0x8001, + data_type = data_types.Uint16 + } + }, + server = { IASActivation = { + ID = 0x8000, + data_type = data_types.Uint16 + } } +} + +local function write_client_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, data_type, + payload) + local message = cluster_base.write_manufacturer_specific_attribute(device, cluster_id, attr_id, mfg_specific_code, + data_type, payload) + + message.body.zcl_header.frame_ctrl:set_direction_client() + return message +end + +local function write_basic_input_polarity_attr(device, ep_id, payload) + local value = data_types.validate_or_build_type(payload and 1 or 0, + BasicInput.attributes.Polarity.base_type, + "payload") + device:send(cluster_base.write_attribute(device, data_types.ClusterId(BasicInput.ID), + data_types.AttributeId(BasicInput.attributes.Polarity.ID), + value):to_endpoint(ep_id)) +end + +local function ensure_child_devices(driver, device) + if device.parent_assigned_child_key ~= nil then + return + end + + for _, info in pairs(OUTPUT_INFO) do + local child = device:get_child_by_parent_assigned_key(info.key) + if child == nil then + driver:try_create_device({ + type = "EDGE_CHILD", + parent_device_id = device.id, + parent_assigned_child_key = info.key, + profile = CHILD_OUTPUT_PROFILE, + label = string.format("%s %s", device.label, info.label_suffix), + vendor_provided_label = info.label_suffix + }) + child = device:get_child_by_parent_assigned_key(info.key) + end + if child then + child:set_field("endpoint", info.endpoint, { persist = true }) + end + end +end + +local function sanitize_timing(value) + local int = math.tointeger(value) or 0 + return utils.clamp_value(int, 0, 0xFFFF) +end + +local function get_output_timing(device, suffix) + local info = OUTPUT_INFO[suffix] + if not info then return 0, 0 end + local child = device:get_child_by_parent_assigned_key(info.key) + local on_time = math.floor((sanitize_timing(device.preferences["configOnTime" .. suffix]))*10) + local off_wait = math.floor((sanitize_timing(device.preferences["configOffWaitTime" .. suffix]))*10) + if child then + on_time = math.floor((sanitize_timing(child.preferences.configOnTime)) * 10) + off_wait = math.floor((sanitize_timing(child.preferences.configOffWaitTime)) * 10) + end + return on_time, off_wait +end + +local function handle_output_command(device, suffix, command_name) + local info = OUTPUT_INFO[suffix] + if info == nil then return end + local config_on_time, config_off_wait_time = get_output_timing(device, suffix) + local endpoint = info.endpoint + + if command_name == "on" then + if config_on_time == 0 then + device:send(OnOff.server.commands.On(device):to_endpoint(endpoint)) + else + device:send(OnOff.server.commands.OnWithTimedOff(device, data_types.Uint8(0), + data_types.Uint16(config_on_time), data_types.Uint16(config_off_wait_time)):to_endpoint(endpoint)) + end + else + device:send(OnOff.server.commands.Off(device):to_endpoint(endpoint)) + end +end + +local function emit_switch_event_for_endpoint(device, endpoint, event) + local info = OUTPUT_BY_ENDPOINT[endpoint] + if info ~= nil then + local child = device:get_child_by_parent_assigned_key(info.key) + if child then + child:emit_event(event) + return + end + end + device:emit_event_for_endpoint(endpoint, event) +end + +local function register_native_switch_handler(device, endpoint) + local field_key = string.format("frient_io_native_%02X", endpoint) + local info = OUTPUT_BY_ENDPOINT[endpoint] + if info ~= nil then + local child = device:get_child_by_parent_assigned_key(info.key) + if child and not child:get_field(field_key) then + child:register_native_capability_attr_handler("switch", "switch") + child:set_field(field_key, true) + end + return + end + + if not device:get_field(field_key) then + device:register_native_capability_attr_handler("switch", "switch") + device:set_field(field_key, true) + end +end + +local function on_off_attr_handler(driver, device, value, zb_message) + local endpoint = zb_message.address_header.src_endpoint.value + register_native_switch_handler(device, endpoint) + emit_switch_event_for_endpoint(device, endpoint, value.value and Switch.switch.on() or Switch.switch.off()) +end + +local function build_bind_request(device, src_cluster, src_ep_id, dest_ep_id) + local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), + device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, bind_request.BindRequest.ID) + + local bind_req = bind_request.BindRequest(device.zigbee_eui, src_ep_id, + src_cluster, + bind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) + local message_body = zdo_messages.ZdoMessageBody({ + zdo_body = bind_req + }) + local bind_cmd = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + return bind_cmd +end + +local function build_unbind_request(device, src_cluster, src_ep_id, dest_ep_id) + local addr_header = messages.AddressHeader(constants.HUB.ADDR, constants.HUB.ENDPOINT, device:get_short_address(), + device.fingerprinted_endpoint_id, constants.ZDO_PROFILE_ID, unbind_request.UNBIND_REQUEST_CLUSTER_ID) + + local unbind_req = unbind_request.UnbindRequest(device.zigbee_eui, src_ep_id, + src_cluster, + unbind_request.ADDRESS_MODE_64_BIT, device.zigbee_eui, dest_ep_id) + local message_body = zdo_messages.ZdoMessageBody({ + zdo_body = unbind_req + }) + local bind_cmd = messages.ZigbeeMessageTx({ + address_header = addr_header, + body = message_body + }) + return bind_cmd +end + +local function apply_input_preference_changes(device, old_prefs, config) + if old_prefs[config.reverse_pref] ~= device.preferences[config.reverse_pref] then + write_basic_input_polarity_attr(device, config.endpoint, device.preferences[config.reverse_pref]) + end + + for _, bind_cfg in ipairs(config.binds) do + if old_prefs[bind_cfg.pref] ~= device.preferences[bind_cfg.pref] then + device:send(device.preferences[bind_cfg.pref] + and build_bind_request(device, BasicInput.ID, config.endpoint, bind_cfg.endpoint) + or build_unbind_request(device, BasicInput.ID, config.endpoint, bind_cfg.endpoint)) + end + end +end + +local function component_to_endpoint(device, component_id) + if component_id == COMPONENTS.INPUT_1 then + return ZIGBEE_ENDPOINTS.INPUT_1 + elseif component_id == COMPONENTS.INPUT_2 then + return ZIGBEE_ENDPOINTS.INPUT_2 + elseif component_id == COMPONENTS.INPUT_3 then + return ZIGBEE_ENDPOINTS.INPUT_3 + elseif component_id == COMPONENTS.INPUT_4 then + return ZIGBEE_ENDPOINTS.INPUT_4 + elseif component_id == COMPONENTS.OUTPUT_1 then + return ZIGBEE_ENDPOINTS.OUTPUT_1 + elseif component_id == COMPONENTS.OUTPUT_2 then + return ZIGBEE_ENDPOINTS.OUTPUT_2 + else + return device.fingerprinted_endpoint_id + end +end + +local function endpoint_to_component(device, ep) + local ep_id = type(ep) == "table" and ep.value or ep + if ep_id == ZIGBEE_ENDPOINTS.INPUT_1 then + return COMPONENTS.INPUT_1 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_2 then + return COMPONENTS.INPUT_2 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_3 then + return COMPONENTS.INPUT_3 + elseif ep_id == ZIGBEE_ENDPOINTS.INPUT_4 then + return COMPONENTS.INPUT_4 + elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_1 then + return COMPONENTS.OUTPUT_1 + elseif ep_id == ZIGBEE_ENDPOINTS.OUTPUT_2 then + return COMPONENTS.OUTPUT_2 + else + return "main" + end +end + +local function init_handler(self, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) +end + +local function added_handler(self, device) + ensure_child_devices(self, device) +end + +local function configure_handler(self, device) + local configuration = configurationMap.get_device_configuration(device) + if configuration ~= nil then + for _, attribute in ipairs(configuration) do + if attribute.configurable ~= false then + device:add_configured_attribute(attribute) + end + end + end + device:configure() + if device.parent_assigned_child_key ~= nil then + return + end + + ensure_child_devices(self, device) + + local on1, off1 = get_output_timing(device, "1") + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off1):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1)) + + local on2, off2 = get_output_timing(device, "2") + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) + device:send(write_client_manufacturer_specific_attribute(device, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off2):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2)) + + -- Input 1 + local default_old_prefs = {} + for _, config in ipairs(INPUT_CONFIGS) do + apply_input_preference_changes(device, default_old_prefs, config) + end +end + +local function info_changed_handler(self, device, event, args) + if device.parent_assigned_child_key ~= nil then + -- This is a child device + local parent = device:get_parent_device() + if not parent then return end + + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if not info then return end + + -- Child devices have simple preference names without suffix + local on_time = math.floor(sanitize_timing(device.preferences.configOnTime) * 10) + local off_wait = math.floor(sanitize_timing(device.preferences.configOffWaitTime) * 10) + + parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OnTime.data_type, + on_time):to_endpoint(info.endpoint)) + + parent:send(write_client_manufacturer_specific_attribute(parent, BasicInput.ID, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.ID, ZIGBEE_MFG_CODES.Develco, + ZIGBEE_MFG_ATTRIBUTES.client.OnWithTimeOff_OffWaitTime.data_type, + off_wait):to_endpoint(info.endpoint)) + return + else + local old_prefs = (args.old_st_store and args.old_st_store.preferences) or {} + for _, config in ipairs(INPUT_CONFIGS) do + apply_input_preference_changes(device, old_prefs, config) + end + end +end + +local function present_value_attr_handler(driver, device, value, zb_message) + local ep_id = zb_message.address_header.src_endpoint + register_native_switch_handler(device, ep_id.value) + device:emit_event_for_endpoint(ep_id, value.value and Switch.switch.on() or Switch.switch.off()) +end + +local function on_off_default_response_handler(driver, device, zb_rx) + local status = zb_rx.body.zcl_body.status.value + local endpoint = zb_rx.address_header.src_endpoint.value + + if status == Status.SUCCESS then + local cmd = zb_rx.body.zcl_body.cmd.value + local event = nil + + if cmd == OnOff.server.commands.On.ID then + event = Switch.switch.on() + elseif cmd == OnOff.server.commands.OnWithTimedOff.ID then + device:send(cluster_base.read_attribute(device, data_types.ClusterId(OnOff.ID), + data_types.AttributeId(OnOff.attributes.OnOff.ID)):to_endpoint(endpoint)) + elseif cmd == OnOff.server.commands.Off.ID then + event = Switch.switch.off() + end + + if event ~= nil then + emit_switch_event_for_endpoint(device, endpoint, event) + end + end +end + +local function make_switch_handler(command_name) + return function(driver, device, command) + local parent = device:get_parent_device() + if parent then + local info = OUTPUT_BY_KEY[device.parent_assigned_child_key] + if info then + handle_output_command(parent, info.suffix, command_name) + return + end + end + + local num = command.component and command.component:match("output(%d)") + if num then + handle_output_command(device, num, command_name) + return + end + num = command.component:match("input(%d)") + if num then + local component = device.profile.components[command.component] + local value = device:get_latest_state(command.component, Switch.ID, Switch.switch.NAME) + if value == "on" then + device:emit_component_event(component, + Switch.switch.on({ state_change = true, visibility = { displayed = false } })) + elseif value == "off" then + device:emit_component_event(component, + Switch.switch.off({ state_change = true, visibility = { displayed = false } })) + end + end + end +end + +local frient_bridge_handler = { + NAME = "frient bridge handler", + zigbee_handlers = { + global = { + [OnOff.ID] = { + [zcl_global_commands.DEFAULT_RESPONSE_ID] = on_off_default_response_handler + } + }, + cluster = {}, + attr = { + [BasicInput.ID] = { + [BasicInput.attributes.PresentValue.ID] = present_value_attr_handler + }, + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = on_off_attr_handler + } + }, + zdo = {} + }, + capability_handlers = { + [Switch.ID] = { + [Switch.commands.on.NAME] = make_switch_handler("on"), + [Switch.commands.off.NAME] = make_switch_handler("off") + } + }, + lifecycle_handlers = { + added = added_handler, + init = configurationMap.reconfig_wrapper(init_handler), + doConfigure = configure_handler, + infoChanged = info_changed_handler + }, + can_handle = require("frient-IO.can_handle"), +} + +return frient_bridge_handler diff --git a/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua b/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua new file mode 100644 index 0000000000..64c4b05464 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/frient-IO/unbind_request.lua @@ -0,0 +1,84 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local data_types = require "st.zigbee.data_types" +local utils = require "st.zigbee.utils" + +local unbind_request = {} + +unbind_request.UNBIND_REQUEST_CLUSTER_ID = 0x0022 +unbind_request.ADDRESS_MODE_16_BIT = 0x01 +unbind_request.ADDRESS_MODE_64_BIT = 0x03 + +local UnbindRequest = { + ID = unbind_request.UNBIND_REQUEST_CLUSTER_ID, + NAME = "UnbindRequest", +} +UnbindRequest.__index = UnbindRequest +unbind_request.UnbindRequest = UnbindRequest + +function UnbindRequest.deserialize(buf) + local self = {} + setmetatable(self, UnbindRequest) + + local fields = { + { name = "src_address", type = data_types.IeeeAddress }, + { name = "src_endpoint", type = data_types.Uint8 }, + { name = "cluster_id", type = data_types.ClusterId }, + { name = "dest_addr_mode", type = data_types.Uint8 }, + } + utils.deserialize_field_list(self, fields, buf) + + if self.dest_addr_mode.value == unbind_request.ADDRESS_MODE_16_BIT then + self.dest_address = data_types.Uint16.deserialize(buf) + else + self.dest_address = data_types.IeeeAddress.deserialize(buf) + self.dest_endpoint = data_types.Uint8.deserialize(buf) + end + return self +end + +--- A helper function used by common code to get all the component pieces of this message frame +function UnbindRequest:get_fields() + local out = {} + out[#out + 1] = self.src_address + out[#out + 1] = self.src_endpoint + out[#out + 1] = self.cluster_id + out[#out + 1] = self.dest_addr_mode + out[#out + 1] = self.dest_address + if self.dest_addr_mode.value == unbind_request.ADDRESS_MODE_64_BIT then + out[#out + 1] = self.dest_endpoint + end + return out +end + +UnbindRequest.get_length = utils.length_from_fields +UnbindRequest._serialize = utils.serialize_from_fields +UnbindRequest.pretty_print = utils.print_from_fields +UnbindRequest.__tostring = UnbindRequest.pretty_print +function UnbindRequest.from_values(orig, src_address, src_endpoint, cluster_id, dest_addr_mode, dest_address, + dest_endpoint) + local out = {} + if src_address == nil or src_endpoint == nil or cluster_id == nil or dest_addr_mode == nil or dest_address == nil then + error("Missing necessary values for bind request", 2) + end + + out.src_address = data_types.validate_or_build_type(src_address, data_types.IeeeAddress, "src_address") + out.src_endpoint = data_types.validate_or_build_type(src_endpoint, data_types.Uint8, "src_endpoint") + out.cluster_id = data_types.validate_or_build_type(cluster_id, data_types.ClusterId, "cluster") + out.dest_addr_mode = data_types.validate_or_build_type(dest_addr_mode, data_types.Uint8, "dest_addr_mode") + if (out.dest_addr_mode.value == unbind_request.ADDRESS_MODE_16_BIT) then + out.dest_address = data_types.validate_or_build_type(dest_address, data_types.Uint16, "dest_address") + elseif out.dest_addr_mode.value == unbind_request.ADDRESS_MODE_64_BIT then + out.dest_address = data_types.validate_or_build_type(dest_address, data_types.IeeeAddress, "dest_address") + out.dest_endpoint = data_types.validate_or_build_type(dest_endpoint, data_types.Uint8, "dest_endpoint") + else + error(string.format("Unrecognized destination address mode: %d", out.dest_addr_mode.value), 2) + end + + setmetatable(out, UnbindRequest) + return out +end + +setmetatable(unbind_request.UnbindRequest, { __call = unbind_request.UnbindRequest.from_values }) + +return unbind_request diff --git a/drivers/SmartThings/zigbee-switch/src/frient/init.lua b/drivers/SmartThings/zigbee-switch/src/frient/init.lua index a615c721f4..e546de1a14 100644 --- a/drivers/SmartThings/zigbee-switch/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/frient/init.lua @@ -152,7 +152,7 @@ local frient_smart_plug = { }, }, lifecycle_handlers = { - init = device_init, + init = configurationMap.reconfig_wrapper(device_init), doConfigure = do_configure, added = device_added, }, diff --git a/drivers/SmartThings/zigbee-switch/src/hanssem/init.lua b/drivers/SmartThings/zigbee-switch/src/hanssem/init.lua index 083893448b..0ab333af8e 100644 --- a/drivers/SmartThings/zigbee-switch/src/hanssem/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/hanssem/init.lua @@ -50,7 +50,7 @@ local HanssemSwitch = { NAME = "Zigbee Hanssem Switch", lifecycle_handlers = { added = device_added, - init = configurations.power_reconfig_wrapper(device_init) + init = configurations.reconfig_wrapper(device_init) }, can_handle = require("hanssem.can_handle"), } diff --git a/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/init.lua b/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/init.lua index a026ac6564..a236f1c1cc 100644 --- a/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/ikea-xy-color-bulb/init.lua @@ -147,7 +147,7 @@ end local ikea_xy_color_bulb = { NAME = "IKEA XY Color Bulb", lifecycle_handlers = { - init = configurationMap.power_reconfig_wrapper(device_init) + init = configurationMap.reconfig_wrapper(device_init) }, capability_handlers = { [capabilities.colorControl.ID] = { diff --git a/drivers/SmartThings/zigbee-switch/src/init.lua b/drivers/SmartThings/zigbee-switch/src/init.lua index 1b694d2a5e..9fbb09119e 100644 --- a/drivers/SmartThings/zigbee-switch/src/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/init.lua @@ -54,8 +54,6 @@ local device_init = function(driver, device) end end -local lazy_load_if_possible = require "lazy_load_subdriver" - local zigbee_switch_driver_template = { supported_capabilities = { capabilities.switch, @@ -69,37 +67,7 @@ local zigbee_switch_driver_template = { capabilities.relativeHumidityMeasurement, capabilities.temperatureMeasurement, }, - sub_drivers = { - lazy_load_if_possible("non_zigbee_devices"), - lazy_load_if_possible("hanssem"), - lazy_load_if_possible("aqara"), - lazy_load_if_possible("aqara-light"), - lazy_load_if_possible("ezex"), - lazy_load_if_possible("rexense"), - lazy_load_if_possible("sinope"), - lazy_load_if_possible("sinope-dimmer"), - lazy_load_if_possible("zigbee-dimmer-power-energy"), - lazy_load_if_possible("zigbee-metering-plug-power-consumption-report"), - lazy_load_if_possible("jasco"), - lazy_load_if_possible("multi-switch-no-master"), - lazy_load_if_possible("zigbee-dual-metering-switch"), - lazy_load_if_possible("rgb-bulb"), - lazy_load_if_possible("zigbee-dimming-light"), - lazy_load_if_possible("white-color-temp-bulb"), - lazy_load_if_possible("rgbw-bulb"), - (version.api < 16) and lazy_load_if_possible("zll-dimmer-bulb") or nil, - lazy_load_if_possible("ikea-xy-color-bulb"), - lazy_load_if_possible("zll-polling"), - lazy_load_if_possible("zigbee-switch-power"), - lazy_load_if_possible("ge-link-bulb"), - lazy_load_if_possible("bad_on_off_data_type"), - lazy_load_if_possible("robb"), - lazy_load_if_possible("wallhero"), - lazy_load_if_possible("inovelli"), -- Combined driver for both VZM31-SN and VZM32-SN - lazy_load_if_possible("laisiao"), - lazy_load_if_possible("tuya-multi"), - lazy_load_if_possible("frient") - }, + sub_drivers = require("sub_drivers"), zigbee_handlers = { global = { [SIMPLE_METERING_ID] = { @@ -112,12 +80,13 @@ local zigbee_switch_driver_template = { }, current_config_version = 1, lifecycle_handlers = { - init = configurationMap.power_reconfig_wrapper(device_init), + init = configurationMap.reconfig_wrapper(device_init), added = lazy_handler("lifecycle_handlers.device_added"), infoChanged = lazy_handler("lifecycle_handlers.info_changed"), doConfigure = lazy_handler("lifecycle_handlers.do_configure"), }, health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_switch_driver_template, zigbee_switch_driver_template.supported_capabilities, diff --git a/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua b/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua index a83a343663..97cd33e821 100644 --- a/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/inovelli/init.lua @@ -258,11 +258,11 @@ local function huePercentToValue(value) else return utils.round(value / 100 * 255) end end -local function getNotificationValue(device, value) +local function getNotificationValue(device, parent, value) local notificationValue = 0 local level = device:get_latest_state("main", capabilities.switchLevel.ID, capabilities.switchLevel.level.NAME) or 100 local color = utils.round(device:get_latest_state("main", capabilities.colorControl.ID, capabilities.colorControl.hue.NAME) or 100) - local effect = device:get_parent_device().preferences.notificationType or 1 + local effect = (parent and parent.preferences.notificationType) or 1 notificationValue = notificationValue + (effect*16777216) notificationValue = notificationValue + (huePercentToValue(value or color)*65536) notificationValue = notificationValue + (level*256) @@ -276,13 +276,16 @@ local function on_handler(driver, device, command) else device:emit_event(capabilities.switch.switch("on")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, PRIVATE_CLUSTER_ID, PRIVATE_CMD_NOTIF_ID, MFG_CODE, - utils.serialize_int(getNotificationValue(device),4,false,false))) + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) end device.thread:call_with_delay(1,send_configuration) end @@ -294,6 +297,9 @@ local function on_handler(driver, device, command) else device:emit_event(capabilities.switch.switch("off")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, @@ -313,13 +319,16 @@ local function switch_level_handler(driver, device, command) device:emit_event(capabilities.switchLevel.level(command.args.level)) device:emit_event(capabilities.switch.switch(command.args.level ~= 0 and "on" or "off")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, PRIVATE_CLUSTER_ID, PRIVATE_CMD_NOTIF_ID, MFG_CODE, - utils.serialize_int(getNotificationValue(device),4,false,false))) + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) end device.thread:call_with_delay(1,send_configuration) end @@ -330,13 +339,16 @@ local function set_color_temperature(driver, device, command) device:emit_event(capabilities.colorTemperature.colorTemperature(command.args.temperature)) device:emit_event(capabilities.switch.switch("on")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, PRIVATE_CLUSTER_ID, PRIVATE_CMD_NOTIF_ID, MFG_CODE, - utils.serialize_int(getNotificationValue(device, 100),4,false,false))) + utils.serialize_int(getNotificationValue(device, dev, 100),4,false,false))) end device.thread:call_with_delay(1,send_configuration) end @@ -346,13 +358,16 @@ local function set_color_temperature(driver, device, command) device:emit_event(capabilities.colorControl.saturation(command.args.color.saturation)) device:emit_event(capabilities.switch.switch("on")) local dev = device:get_parent_device() + if dev == nil then + return + end local send_configuration = function() dev:send(cluster_base.build_manufacturer_specific_command( dev, PRIVATE_CLUSTER_ID, PRIVATE_CMD_NOTIF_ID, MFG_CODE, - utils.serialize_int(getNotificationValue(device),4,false,false))) + utils.serialize_int(getNotificationValue(device, dev),4,false,false))) end device.thread:call_with_delay(1,send_configuration) end diff --git a/drivers/SmartThings/zigbee-switch/src/laisiao/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/laisiao/can_handle.lua index 7ed921844b..51a344c104 100644 --- a/drivers/SmartThings/zigbee-switch/src/laisiao/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/laisiao/can_handle.lua @@ -4,6 +4,7 @@ return function(opts, driver, device, ...) local FINGERPRINTS = { { mfr = "LAISIAO", model = "yuba" }, + { mfr = "LAISIAO", model = "DG60GCM-04-2904W" }, } for _, fingerprint in ipairs(FINGERPRINTS) do diff --git a/drivers/SmartThings/zigbee-switch/src/laisiao/init.lua b/drivers/SmartThings/zigbee-switch/src/laisiao/init.lua index 2833843793..8fad192232 100755 --- a/drivers/SmartThings/zigbee-switch/src/laisiao/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/laisiao/init.lua @@ -5,8 +5,6 @@ local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local configurations = require "configurations" - - local function component_to_endpoint(device, component_id) if component_id == "main" then return device.fingerprinted_endpoint_id @@ -48,7 +46,7 @@ local laisiao_bath_heater = { capabilities.switch, }, lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(device_init), + init = configurations.reconfig_wrapper(device_init), }, capability_handlers = { [capabilities.switch.ID] = { diff --git a/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/do_configure.lua b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/do_configure.lua index 465969a755..4058a953bb 100644 --- a/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/do_configure.lua +++ b/drivers/SmartThings/zigbee-switch/src/lifecycle_handlers/do_configure.lua @@ -17,4 +17,11 @@ return function(self, device) device:send(clusters.SimpleMetering.attributes.Divisor:read(device)) device:send(clusters.SimpleMetering.attributes.Multiplier:read(device)) end + + if device:supports_capability(capabilities.colorTemperature) then + local clusters = require "st.zigbee.zcl.clusters" + -- min and max for color temperature + device:send(clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:read(device)) + device:send(clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:read(device)) + end end diff --git a/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/init.lua b/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/init.lua index ba0ed07ae3..bac6368ad2 100644 --- a/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/multi-switch-no-master/init.lua @@ -49,7 +49,7 @@ end local multi_switch_no_master = { NAME = "multi switch no master", lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(device_init), + init = configurations.reconfig_wrapper(device_init), added = device_added }, can_handle = require("multi-switch-no-master.can_handle"), diff --git a/drivers/SmartThings/zigbee-switch/src/robb/init.lua b/drivers/SmartThings/zigbee-switch/src/robb/init.lua index 1a8c2948c3..aa2487c87a 100644 --- a/drivers/SmartThings/zigbee-switch/src/robb/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/robb/init.lua @@ -32,7 +32,7 @@ local robb_dimmer_handler = { } }, lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(do_init) + init = configurations.reconfig_wrapper(do_init) }, can_handle = require("robb.can_handle"), } diff --git a/drivers/SmartThings/zigbee-switch/src/stateless_handlers/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/can_handle.lua new file mode 100644 index 0000000000..845bd33156 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +return function(opts, driver, device) + local can_handle = device:supports_capability(capabilities.statelessColorTemperatureStep) + or device:supports_capability(capabilities.statelessSwitchLevelStep) + if can_handle then + local subdriver = require("stateless_handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua new file mode 100644 index 0000000000..07adfe0e84 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/stateless_handlers/init.lua @@ -0,0 +1,97 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local constants = require "st.zigbee.constants" +local clusters = require "st.zigbee.zcl.clusters" +local switch_utils = require "switch_utils" + +-- These values are the mired versions of the config bounds in the default profile (e.g. color-temp-bulb) +local DEFAULT_MIRED_MAX_BOUND = 370 -- 2700 Kelvin (Mireds are the inverse of Kelvin) +local DEFAULT_MIRED_MIN_BOUND = 154 -- 6500 Kelvin (Mireds are the inverse of Kelvin) + +-- Transition Time: The time that shall be taken to perform the step change, in units of 1/10ths of a second. +-- Specific fields can store custom transition times for stateless capabilities +local SWITCH_LEVEL_STEP_TRANSITION_TIME = "__switch_level_step_transition_time" +local COLOR_TEMP_STEP_TRANSITION_TIME = "__color_temp_step_transition_time" +local DEFAULT_STEP_TRANSITION_TIME = 3 -- 0.3 seconds + +-- Options Mask & Override: Indicates which options are being overridden by the Level/ColorControl cluster commands +local OPTIONS_MASK = 0x01 -- default: The `ExecuteIfOff` option is overriden +local IGNORE_COMMAND_IF_OFF = 0x00 -- default: the command will not be executed if the device is off + +-- Indicates whether a delayed refresh for ZLL devices is in progress, to prevent multiple refreshes in a quick series of step commands +local IS_REFRESH_CALLBACK_QUEUED = "__is_refresh_callback_queued" +-- Stores a timer object, which is required to cancel a timer early +local REFRESH_CALLBACK_TIMER = "__refresh_callback_timer" + +-- Note: These commands' native handlers do not match the driver's ZLL behavior 1-1. +-- Instead, they will queue a 2s timer and read refresh for each command, in all cases. +local function trigger_delayed_refresh_if_zll(device) + if device:get_profile_id() ~= constants.ZLL_PROFILE_ID then + return + end + + -- If a refresh callback is already queued, cancel it and create a new one with the updated time + if device:get_field(IS_REFRESH_CALLBACK_QUEUED) then + device.thread:cancel_timer(device:get_field(REFRESH_CALLBACK_TIMER)) + end + local delay_s = 2 + local new_timer = device.thread:call_with_delay(delay_s, function() + device:refresh() + device:set_field(IS_REFRESH_CALLBACK_QUEUED, nil) + end) + device:set_field(REFRESH_CALLBACK_TIMER, new_timer) + device:set_field(IS_REFRESH_CALLBACK_QUEUED, true) +end + +local function step_color_temperature_by_percent_handler(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local step_percent_change = cmd.args and cmd.args.stepSize or 0 + if step_percent_change == 0 then return end + local transition_time = device:get_field(COLOR_TEMP_STEP_TRANSITION_TIME) or DEFAULT_STEP_TRANSITION_TIME + -- Reminder, stepSize > 0 == Kelvin UP == Mireds DOWN. stepSize < 0 == Kelvin DOWN == Mireds UP + local step_mode = (step_percent_change > 0) and clusters.ColorControl.types.CcStepMode.DOWN or clusters.ColorControl.types.CcStepMode.UP + -- note: the field containing the color temp bounds will be associated with a parent device + local field_device = device:get_parent_device() or device + local min_mireds = field_device:get_field(switch_utils.MIRED_MIN_BOUND) + local max_mireds = field_device:get_field(switch_utils.MIRED_MAX_BOUND) + -- since colorTemperatureRange is only set after both custom bounds are, use defaults if any custom bound is missing + if not (min_mireds and max_mireds) then + min_mireds = DEFAULT_MIRED_MIN_BOUND + max_mireds = DEFAULT_MIRED_MAX_BOUND + end + local step_size_in_mireds = st_utils.round((max_mireds - min_mireds) * (math.abs(step_percent_change)/100.0)) + device:send(clusters.ColorControl.server.commands.StepColorTemperature(device, step_mode, step_size_in_mireds, transition_time, min_mireds, max_mireds, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF)) + trigger_delayed_refresh_if_zll(device) +end + +local function step_level_handler(driver, device, cmd) + if type(device.register_native_capability_cmd_handler) == "function" then + device:register_native_capability_cmd_handler(cmd.capability, cmd.command) + end + local step_size = st_utils.round((cmd.args and cmd.args.stepSize or 0)/100.0 * 254) + if step_size == 0 then return end + local transition_time = device:get_field(SWITCH_LEVEL_STEP_TRANSITION_TIME) or DEFAULT_STEP_TRANSITION_TIME + local step_mode = (step_size > 0) and clusters.Level.types.MoveStepMode.UP or clusters.Level.types.MoveStepMode.DOWN + device:send(clusters.Level.server.commands.Step(device, step_mode, math.abs(step_size), transition_time, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF)) + trigger_delayed_refresh_if_zll(device) +end + +local stateless_handlers = { + NAME = "Zigbee Stateless Step Handlers", + capability_handlers = { + [capabilities.statelessColorTemperatureStep.ID] = { + [capabilities.statelessColorTemperatureStep.commands.stepColorTemperatureByPercent.NAME] = step_color_temperature_by_percent_handler, + }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = step_level_handler, + }, + }, + can_handle = require("stateless_handlers.can_handle") +} + +return stateless_handlers diff --git a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua new file mode 100644 index 0000000000..60390d8074 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua @@ -0,0 +1,42 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local version = require "version" + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("non_zigbee_devices"), + lazy_load_if_possible("hanssem"), + lazy_load_if_possible("aqara"), + lazy_load_if_possible("aqara-light"), + lazy_load_if_possible("ezex"), + lazy_load_if_possible("rexense"), + lazy_load_if_possible("sinope"), + lazy_load_if_possible("sinope-dimmer"), + lazy_load_if_possible("zigbee-dimmer-power-energy"), + lazy_load_if_possible("zigbee-metering-plug-power-consumption-report"), + lazy_load_if_possible("jasco"), + lazy_load_if_possible("multi-switch-no-master"), + lazy_load_if_possible("zigbee-dual-metering-switch"), + lazy_load_if_possible("rgb-bulb"), + lazy_load_if_possible("zigbee-dimming-light"), + lazy_load_if_possible("white-color-temp-bulb"), + lazy_load_if_possible("rgbw-bulb"), + (version.api < 16) and lazy_load_if_possible("zll-dimmer-bulb") or nil, + lazy_load_if_possible("ikea-xy-color-bulb"), + lazy_load_if_possible("zll-polling"), + lazy_load_if_possible("zigbee-switch-power"), + lazy_load_if_possible("ge-link-bulb"), + lazy_load_if_possible("bad_on_off_data_type"), + lazy_load_if_possible("robb"), + lazy_load_if_possible("wallhero"), + lazy_load_if_possible("inovelli"), -- Combined driver for both VZM31-SN and VZM32-SN + lazy_load_if_possible("laisiao"), + lazy_load_if_possible("tuya-multi"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("frient-IO"), + lazy_load_if_possible("color_temp_range_handlers"), + lazy_load_if_possible("stateless_handlers"), + lazy_load_if_possible("firstled-io"), + lazy_load_if_possible("firstled-light") +} diff --git a/drivers/SmartThings/zigbee-switch/src/switch_utils.lua b/drivers/SmartThings/zigbee-switch/src/switch_utils.lua index 66ad4715f9..d30ada0588 100644 --- a/drivers/SmartThings/zigbee-switch/src/switch_utils.lua +++ b/drivers/SmartThings/zigbee-switch/src/switch_utils.lua @@ -1,8 +1,19 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local st_utils = require "st.utils" + local switch_utils = {} +switch_utils.MIRED_MAX_BOUND = "__max_mired_bound" +switch_utils.MIRED_MIN_BOUND = "__min_mired_bound" + +switch_utils.MIREDS_CONVERSION_CONSTANT = 1000000 + +switch_utils.convert_mired_to_kelvin = function(mired) + return st_utils.round(switch_utils.MIREDS_CONVERSION_CONSTANT / mired) +end + switch_utils.emit_event_if_latest_state_missing = function(device, component, capability, attribute_name, value) if device:get_latest_state(component, capability.ID, attribute_name) == nil then device:emit_event(value) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_all_capability_zigbee_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_all_capability_zigbee_bulb.lua index 70dfc96780..1332185693 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_all_capability_zigbee_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_all_capability_zigbee_bulb.lua @@ -25,8 +25,10 @@ local zigbee_bulb_all_caps = { capabilities = { [capabilities.switch.ID] = { id = capabilities.switch.ID }, [capabilities.switchLevel.ID] = { id = capabilities.switchLevel.ID }, + [capabilities.statelessSwitchLevelStep.ID] = { id = capabilities.statelessSwitchLevelStep.ID }, [capabilities.colorControl.ID] = { id = capabilities.colorControl.ID }, [capabilities.colorTemperature.ID] = { id = capabilities.colorTemperature.ID }, + [capabilities.statelessColorTemperatureStep.ID] = { id = capabilities.statelessColorTemperatureStep.ID }, [capabilities.powerMeter.ID] = { id = capabilities.powerMeter.ID }, [capabilities.energyMeter.ID] = { id = capabilities.energyMeter.ID }, [capabilities.refresh.ID] = { id = capabilities.refresh.ID }, @@ -99,6 +101,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -124,6 +129,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -149,6 +157,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -175,6 +186,9 @@ test.register_message_test( math.floor(57 * 0xFE / 100), 0) } } + }, + { + min_api_version = 14 } ) @@ -200,6 +214,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -217,6 +234,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27000.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -233,7 +253,18 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.colorControl.hue(0)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "hue" } + } } + }, + { + min_api_version = 19 } ) @@ -250,8 +281,187 @@ test.register_message_test( channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.colorControl.saturation(50)) + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_attr_handler", + { device_uuid = mock_device.id, capability_id = "colorControl", capability_attr_id = "saturation" } + } + } + }, + { + min_api_version = 19 + } +) + +local DEFAULT_MIRED_MAX = 370 +local DEFAULT_MIRED_MIN = 154 +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 + +test.register_message_test( + "Step ColorTemperature command test", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 43, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 90 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 194, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { -50 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessColorTemperatureStep", capability_cmd_id = "stepColorTemperatureByPercent" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.UP, 108, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }, + } + }, + { + min_api_version = 19 + } +) + +test.register_message_test( + "Step Level command test", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }, + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { -50 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.DOWN, 127, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + } + }, + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 100 } } + } + }, + { + channel = "devices", + direction = "send", + message = { + "register_native_capability_cmd_handler", + { device_uuid = mock_device.id, capability_id = "statelessSwitchLevelStep", capability_cmd_id = "stepLevel" } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 254, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) } } + }, + { + min_api_version = 19 + } ) test.register_coroutine_test( @@ -377,11 +587,161 @@ test.register_coroutine_test( mock_device.id, SimpleMetering.attributes.Divisor:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15, + max_api_version = 19 + } ) +test.register_coroutine_test( + "lifecycle configure event should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentHue:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentSaturation:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + ColorControl.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 0x0010) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 0x0010) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 0x0010) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Multiplier:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + SimpleMetering.attributes.Divisor:read(mock_device) + }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } +) -- test.register_coroutine_test( -- "health check coroutine", -- function() @@ -429,13 +789,15 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) test.register_coroutine_test( "configuration version below 1 config response not success", function() + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") assert(mock_device:get_field("_configuration_version") == nil) test.mock_device.add_test_device(mock_device) @@ -443,6 +805,8 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 600, 5)}) test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) test.mock_time.advance_time(5*60 + 1) test.wait_for_events() test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, Status.UNSUPPORTED_ATTRIBUTE)}) @@ -453,13 +817,15 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) test.register_coroutine_test( "configuration version below 1 individual config response records ElectricalMeasurement", function() + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") assert(mock_device:get_field("_configuration_version") == nil) test.mock_device.add_test_device(mock_device) @@ -467,6 +833,8 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 600, 5)}) test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) test.mock_time.advance_time(5*60 + 1) test.wait_for_events() test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, ElectricalMeasurement.ID, nil, ElectricalMeasurement.attributes.ActivePower.ID, Status.SUCCESS)}) @@ -476,13 +844,15 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) test.register_coroutine_test( "configuration version below 1 individual config response records SimpleMetering", function() + test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") test.timer.__create_and_queue_test_time_advance_timer(5*60, "oneshot") assert(mock_device:get_field("_configuration_version") == nil) test.mock_device.add_test_device(mock_device) @@ -490,6 +860,8 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__expect_send({mock_device.id, ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 600, 5)}) test.socket.zigbee:__expect_send({mock_device.id, SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 600, 5)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) test.mock_time.advance_time(5*60 + 1) test.wait_for_events() test.socket.zigbee:__queue_receive({mock_device.id, build_config_response_msg(mock_device, SimpleMetering.ID, nil, SimpleMetering.attributes.InstantaneousDemand.ID, Status.SUCCESS)}) @@ -499,7 +871,8 @@ test.register_coroutine_test( { test_init = function() -- no op to override auto device add on startup - end + end, + min_api_version = 14 } ) @@ -534,7 +907,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) -- This tests that our code responsible for handling conversion errors from Kelvin<->Mireds works as expected @@ -550,6 +926,19 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:build_test_attr_report(mock_device, 556)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800))) mock_device:expect_native_attr_handler_registration("colorTemperature", "colorTemperature") + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Color temperature range report test", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({mock_device.id, clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_attr_report(mock_device, 370)}) + test.socket.zigbee:__queue_receive({mock_device.id, clusters.ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_attr_report(mock_device, 153)}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2703, maximum = 6536}))) end ) @@ -571,7 +960,10 @@ test.register_coroutine_test( --- offset should be reset by a reading under the previous offset test.socket.zigbee:__queue_receive({mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:build_test_attr_report(mock_device, 14)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 14.0, unit = "kWh" }))) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_dual_relay_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_dual_relay_module.lua new file mode 100644 index 0000000000..3dd95f7bb1 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_dual_relay_module.lua @@ -0,0 +1,268 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local OnOff = clusters.OnOff +local ElectricalMeasurement = clusters.ElectricalMeasurement +local SimpleMetering = clusters.SimpleMetering + +local PRIVATE_CLUSTER_ID = 0xFCC0 +local MFG_CODE = 0x115F +local INTERLOCK_ATTRIBUTE_ID = 0x02D0 +local DEVICE_MODE_ATTRIBUTE_ID = 0x0289 +local POWER_OFF_MEMORY_ATTRIBUTE_ID = 0x0517 +local PULSE_INTERVAL_ATTRIBUTE_ID = 0x00EB +local ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID = 0x000A +local CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID = 0x0200 + +local PRIVATE_MODE = "PRIVATE_MODE" + +local SUPPORTED_INTERLOCK = { "normal", "interlock" } +local SUPPORTED_DEVICE_MODE = { "wet_contact_mode", "dry_contact_closed_pulse_mode", "dry_contact_on_off_mode" } + +-- acn047 (Dual Relay Module T2) runs on standard clusters (never Aqara private mode) +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-dual-relay-module-unified.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Aqara", + model = "lumi.switch.acn047", + server_clusters = { OnOff.ID, ElectricalMeasurement.ID, SimpleMetering.ID } + } + } + } +) + +-- acn047 reports children = 2, so a single child device (relay 2) exists on the second endpoint +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("aqara-switch-child.yml"), + device_network_id = string.format("%04X:%02X", mock_device:get_short_address(), 2), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_child) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added: child is reused, button info reported, power/energy restored (0.0 on first add) and no private write", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 2 }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("interlock", + capabilities.mode.supportedModes(SUPPORTED_INTERLOCK, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("devicemode", + capabilities.mode.supportedModes(SUPPORTED_DEVICE_MODE, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 0.0, unit = "W" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" }))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "refresh reads OnOff, power/energy (standard clusters) and the interlock/devicemode attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, INTERLOCK_ATTRIBUTE_ID, MFG_CODE) }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, DEVICE_MODE_ATTRIBUTE_ID, MFG_CODE) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "switch on command : parent device", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "switch off command : child device (endpoint 2)", + function() + test.socket.capability:__queue_receive({ mock_child.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + mock_child:expect_native_cmd_handler_registration("switch", "off") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "power meter report (standard ElectricalMeasurement cluster)", + function() + mock_device:set_field(PRIVATE_MODE, 0, { persist = true }) + test.socket.zigbee:__queue_receive({ + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:build_test_attr_report(mock_device, 100) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 10.0, unit = "W" })) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "interlock attribute report updates the interlock component", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, { + { INTERLOCK_ATTRIBUTE_ID, data_types.Boolean.ID, true } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("interlock", + capabilities.mode.mode("interlock"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "device mode attribute report updates the devicemode component", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRIVATE_CLUSTER_ID, { + { DEVICE_MODE_ATTRIBUTE_ID, data_types.Uint8.ID, 1 } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("devicemode", + capabilities.mode.mode("dry_contact_closed_pulse_mode"))) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode on the interlock component writes the interlock attribute", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "mode", component = "interlock", command = "setMode", args = { "interlock" } } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, INTERLOCK_ATTRIBUTE_ID, + MFG_CODE, data_types.Boolean, true) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode on the devicemode component (on/off) writes device value 3", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "mode", component = "devicemode", command = "setMode", args = { "dry_contact_on_off_mode" } } }) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, DEVICE_MODE_ATTRIBUTE_ID, + MFG_CODE, data_types.Uint8, 3) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "preference powerOffMemory is written on infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.powerOffMemory"] = "on" } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + POWER_OFF_MEMORY_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "preference pulseInterval is written on infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.pulseIntervalSetting"] = 500 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + PULSE_INTERVAL_ATTRIBUTE_ID, MFG_CODE, data_types.Uint16, 500) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "preference switchType (nc) is written on infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.switchType"] = "nc" } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "preference changeToWirelessSwitch is written on infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.changeToWirelessSwitch"] = true } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, + CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_led_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_led_bulb.lua index b2f9359850..f1dc9e1ea4 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_led_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_led_bulb.lua @@ -38,7 +38,6 @@ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6000 }))) end test.set_test_init_function(test_init) @@ -52,8 +51,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - -- test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6000 }))) - end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6000 }))) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -105,7 +107,78 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColorTemperature(mock_device, 200, 0) + } + ) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -129,7 +202,10 @@ test.register_coroutine_test( ColorControl.commands.MoveToColorTemperature(mock_device, temp_in_mired, 0x0000) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -141,7 +217,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_light.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_light.lua index 940131b3e1..16ceec4e24 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_light.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_light.lua @@ -40,7 +40,6 @@ zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6000 }))) end test.set_test_init_function(test_init) @@ -54,8 +53,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - -- test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6000 }))) - end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6000 }))) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -104,13 +106,86 @@ test.register_coroutine_test( OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } +) + + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnTransitionTime:write(mock_device, 0) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OffTransitionTime:write(mock_device, 0) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColorTemperature(mock_device, 200, 0) + } + ) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -134,7 +209,10 @@ test.register_coroutine_test( ColorControl.commands.MoveToColorTemperature(mock_device, temp_in_mired, 0x0000) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -146,7 +224,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -158,7 +239,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, TURN_OFF_INDICATOR_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -168,7 +252,10 @@ test.register_coroutine_test( preferences = { ["stse.lightFadeInTimeInSec"] = 1 } })) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnTransitionTime:write(mock_device, 10) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -178,7 +265,45 @@ test.register_coroutine_test( preferences = { ["stse.lightFadeOutTimeInSec"] = 1 } })) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OffTransitionTime:write(mock_device, 10) }) - end + end, + { + min_api_version = 14 + } +) + +local mock_device_cwacn1 = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-light.yml"), + preferences = { ["stse.lightFadeInTimeInSec"] = 0, ["stse.lightFadeOutTimeInSec"] = 0 }, + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.light.cwacn1", + server_clusters = { 0x0006, 0x0008, 0x0300 } + } + } + } +) + +test.register_coroutine_test( + "Handle added lifecycle for lumi.light.cwacn1 model (colorTemperatureRange max = 6500)", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device_cwacn1.id, "added" }) + + test.socket.zigbee:__expect_send({ + mock_device_cwacn1.id, cluster_base.write_manufacturer_specific_attribute(mock_device_cwacn1, PRIVATE_CLUSTER_ID, + PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) + test.socket.capability:__expect_send(mock_device_cwacn1:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6500 }))) + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device_cwacn1) + end, + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug.lua index 0d51bd11c2..cffacc4451 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug.lua @@ -76,7 +76,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -115,7 +118,10 @@ test.register_coroutine_test( SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -131,7 +137,10 @@ test.register_coroutine_test( AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -145,7 +154,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -159,7 +171,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -170,7 +185,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -181,7 +199,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -200,7 +221,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -232,7 +256,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -244,7 +271,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -256,7 +286,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, MAX_POWER_ATTRIBUTE_ID, MFG_CODE, data_types.SinglePrecisionFloat, SinglePrecisionFloat(0, 6, 0.5625)) }) - end + end, + { + min_api_version = 14 + } ) -- with standard cluster @@ -282,7 +315,10 @@ test.register_coroutine_test( ElectricalMeasurement.attributes.ActivePower:read(mock_standard) }) test.socket.zigbee:__expect_send({ mock_standard.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_standard) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -305,7 +341,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_standard:generate_test_message("main", capabilities.powerMeter.power({ value = 10.0, unit = "W" })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -327,7 +366,10 @@ test.register_coroutine_test( mock_standard:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 10 })) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug_t1.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug_t1.lua index 8a544caf13..21c697ed1c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug_t1.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_smart_plug_t1.lua @@ -77,7 +77,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -116,7 +119,10 @@ test.register_coroutine_test( SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -132,7 +138,10 @@ test.register_coroutine_test( AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -146,7 +155,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -160,7 +172,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -171,7 +186,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -182,7 +200,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -201,7 +222,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -233,7 +257,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -245,7 +272,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -257,7 +287,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, MAX_POWER_ATTRIBUTE_ID, MFG_CODE, data_types.SinglePrecisionFloat, SinglePrecisionFloat(0, 6, 0.5625)) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -269,7 +302,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_TURN_OFF_INDICATOR_LIGHT_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) -- with standard cluster @@ -295,7 +331,10 @@ test.register_coroutine_test( ElectricalMeasurement.attributes.ActivePower:read(mock_standard) }) test.socket.zigbee:__expect_send({ mock_standard.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_standard) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -318,7 +357,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_standard:generate_test_message("main", capabilities.powerMeter.power({ value = 10.0, unit = "W" })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -340,7 +382,10 @@ test.register_coroutine_test( mock_standard:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 10 })) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module.lua index f27d84cfc6..93e21c4844 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module.lua @@ -54,7 +54,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -68,7 +71,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -82,7 +88,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -93,7 +102,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -104,7 +116,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -123,7 +138,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -147,7 +165,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -159,7 +180,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -171,7 +195,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module_no_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module_no_power.lua index 840aadacb9..14a816183d 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module_no_power.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_module_no_power.lua @@ -46,7 +46,10 @@ test.register_coroutine_test( function() test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -57,7 +60,10 @@ test.register_coroutine_test( { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) -end +end, +{ + min_api_version = 14 +} ) test.register_coroutine_test( @@ -67,7 +73,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x01) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -77,7 +86,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x01) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -88,7 +100,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -99,7 +114,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -111,7 +129,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -123,7 +144,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, ELECTRIC_SWITCH_TYPE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_no_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_no_power.lua index 207e7bf21b..74e6eaaa6a 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_no_power.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_no_power.lua @@ -75,74 +75,100 @@ end test.set_test_init_function(test_init) test.register_coroutine_test( - "Lifecycle - added test", + "Lifecycle - added test : parent device", function() - -- The initial switch event should be send during the device's first time onboarding + -- The initial switch event should be send during the device's first time onboarding. + -- This profile has no powerMeter/energyMeter, so no meter initialization event may be sent: + -- any extra capability event would fail the strict channel assertions below. test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 2 }, - { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 2 }, + { visibility = { displayed = false } }))) test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, - { visibility = { displayed = false } }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) -- Avoid sending the initial switch event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 2 }, - { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 2 }, + { visibility = { displayed = false } }))) test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, - { visibility = { displayed = false } }))) - end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Lifecycle - added test", + "Lifecycle - added test : child device", function() - -- The initial switch event should be send during the device's first time onboarding + -- The initial switch event should be send during the device's first time onboarding. + -- The child profile has no powerMeter/energyMeter either, so no meter initialization event is sent. test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_child.id, "added" }) - test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.numberOfButtons({ value = 1 }, - { visibility = { displayed = false } }))) - test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, - { visibility = { displayed = false } }))) - test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) -- Avoid sending the initial switch event after driver switch-over, as the switch-over event itself re-triggers the added lifecycle. test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_child.id, "added" }) - test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.numberOfButtons({ value = 1 }, - { visibility = { displayed = false } }))) - test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, - { visibility = { displayed = false } }))) - end + test.socket.capability:__expect_send(mock_child:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_child:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( - "Lifecycle - added test", + "Lifecycle - added test : child device creation", function() -- The initial switch event should be send during the device's first time onboarding test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_base_device.id, "added" }) - mock_base_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Aqara Smart Wall Switch H1 EU (No Neutral, Double Rocker) 2", - profile = "aqara-switch-child", - parent_device_id = mock_base_device.id, - parent_assigned_child_key = "02" - }) - test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 2 }, - { visibility = { displayed = false } }))) + mock_base_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Aqara Smart Wall Switch H1 EU (No Neutral, Double Rocker) 2", + profile = "aqara-switch-child", + parent_device_id = mock_base_device.id, + parent_assigned_child_key = "02" + }) + test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 2 }, + { visibility = { displayed = false } }))) test.socket.zigbee:__expect_send({ mock_base_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_base_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, + cluster_base.write_manufacturer_specific_attribute(mock_base_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, + MFG_CODE, data_types.Uint8, 1) }) - test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, - { visibility = { displayed = false } }))) - test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) - end + test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_base_device:generate_test_message("main", + capabilities.button.button.pushed({ state_change = false }))) + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -153,7 +179,10 @@ test.register_coroutine_test( { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -163,7 +192,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x01) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -173,7 +205,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.on())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -183,7 +218,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x01) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -193,7 +231,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x02) }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.off())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -204,7 +245,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -215,7 +259,10 @@ test.register_coroutine_test( mock_child:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -226,7 +273,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -237,7 +287,10 @@ test.register_coroutine_test( mock_child:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -251,7 +304,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -265,7 +321,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) @@ -279,7 +338,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -291,7 +353,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_power.lua index a02ef37aa2..9c1f27b33c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_power.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_switch_power.lua @@ -69,26 +69,79 @@ end test.set_test_init_function(test_init) test.register_coroutine_test( - "Lifecycle - added test", + "Lifecycle - added test : parent device", function() test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 3 }, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, + data_types.Uint8, 1) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) + -- On the first onboarding there is no previous power/energy state, so both meters are initialized to 0 test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 0.0, unit = "W" }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.0, unit = "Wh" }))) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Lifecycle - added test : power/energy already reported should be restored instead of reset to 0", + function() + mock_device:set_field(PRIVATE_MODE, 1, { persist = true }) + mock_device:set_field(LAST_REPORT_TIME, os.time() - 60 * 20) + test.socket.zigbee:__set_channel_ordering("relaxed") + + -- report power and energy first so the device has a last known meter state + test.socket.zigbee:__queue_receive({ + mock_device.id, + AnalogInput.attributes.PresentValue:build_test_attr_report(mock_device, + SinglePrecisionFloat(0, 9, 0.953125)):from_endpoint(POWER_METER_ENDPOINT) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.powerMeter.power({ value = 1000.0, unit = "W" }))) + test.socket.zigbee:__expect_send({ mock_device.id, + AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + AnalogInput.attributes.PresentValue:build_test_attr_report(mock_device, + SinglePrecisionFloat(0, 9, 0.953125)):from_endpoint(ENERGY_METER_ENDPOINT) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ value = 1000000.0, unit = "Wh" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 }))) + test.wait_for_events() + + -- re-adding the device must restore the reported values rather than blank them out + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.numberOfButtons({ value = 3 }, + { visibility = { displayed = false } }))) test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 1) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) - - end + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.powerMeter.power({ value = 1000.0, unit = "W" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.energyMeter.energy({ value = 1000000.0, unit = "Wh" }))) + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( - "Lifecycle - added test", + "Lifecycle - added test : child device", function() test.socket.zigbee:__set_channel_ordering("relaxed") test.socket.device_lifecycle:__queue_receive({ mock_child.id, "added" }) @@ -97,7 +150,11 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = false }))) - end + -- the child profile has no powerMeter/energyMeter, so no meter initialization event is sent + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -113,7 +170,10 @@ test.register_coroutine_test( AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -127,7 +187,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -141,7 +204,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -155,7 +221,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -169,7 +238,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(POWER_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -180,7 +252,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -191,7 +266,10 @@ test.register_coroutine_test( mock_child:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -202,7 +280,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -213,7 +294,10 @@ test.register_coroutine_test( mock_child:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -227,7 +311,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -241,7 +328,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -260,7 +350,10 @@ test.register_coroutine_test( ) test.socket.zigbee:__expect_send({ mock_device.id, AnalogInput.attributes.PresentValue:read(mock_device):to_endpoint(ENERGY_METER_ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -284,7 +377,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 1000000.0 })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -296,7 +392,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, RESTORE_POWER_STATE_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, true) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -308,7 +407,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_wall_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_wall_switch.lua index 3711116d99..7efbeefe78 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_wall_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_wall_switch.lua @@ -58,7 +58,10 @@ test.register_coroutine_test( OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x01) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) mock_device:expect_native_attr_handler_registration("switch", "switch") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -68,7 +71,10 @@ test.register_coroutine_test( OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.on())) mock_device:expect_native_attr_handler_registration("switch", "switch") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -78,7 +84,10 @@ test.register_coroutine_test( OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x01) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) mock_device:expect_native_attr_handler_registration("switch", "switch") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -88,7 +97,10 @@ test.register_coroutine_test( OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x02) }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.switch.switch.off())) mock_device:expect_native_attr_handler_registration("switch", "switch") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -99,7 +111,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -110,7 +125,10 @@ test.register_coroutine_test( mock_child:expect_native_cmd_handler_registration("switch", "on") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -121,7 +139,10 @@ test.register_coroutine_test( mock_device:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -132,7 +153,10 @@ test.register_coroutine_test( mock_child:expect_native_cmd_handler_registration("switch", "off") test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -146,7 +170,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -160,7 +187,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_child:generate_test_message("main", capabilities.button.button.pushed({ state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -172,7 +202,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, CHANGE_TO_WIRELESS_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aurora_relay.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aurora_relay.lua index 57d6576955..4b16a32bb5 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aurora_relay.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aurora_relay.lua @@ -40,7 +40,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -64,6 +67,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -88,6 +94,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -112,7 +121,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 60.0, unit = "W" })) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_bad_data_type.lua b/drivers/SmartThings/zigbee-switch/src/test/test_bad_data_type.lua index b30f49df23..296ab9a904 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_bad_data_type.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_bad_data_type.lua @@ -41,6 +41,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_bad_device_kind.lua b/drivers/SmartThings/zigbee-switch/src/test/test_bad_device_kind.lua index bd8ce15b10..569c5c6b98 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_bad_device_kind.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_bad_device_kind.lua @@ -32,7 +32,9 @@ test.register_coroutine_test("zwave_device_handled", function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", dkjson.encode(mock_device.raw_st_data) }) test.wait_for_events() end, - nil + { + min_api_version = 14 + } ) test.register_message_test( @@ -41,6 +43,9 @@ test.register_message_test( channel = "capability", direction = "receive", message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_cree_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_cree_bulb.lua index 8841c5a53d..b8cfb47e41 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_cree_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_cree_bulb.lua @@ -64,7 +64,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -72,7 +75,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -82,7 +88,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -93,7 +102,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_duragreen_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_duragreen_color_temp_bulb.lua index 55c491e77d..5651b0086b 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_duragreen_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_duragreen_color_temp_bulb.lua @@ -38,6 +38,9 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) @@ -69,12 +72,135 @@ test.register_coroutine_test( ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14, + max_api_version = 19 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 20 + } ) test.register_message_test( @@ -111,7 +237,9 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14, + max_api_version = 19 } ) @@ -139,7 +267,10 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -158,7 +289,10 @@ test.register_coroutine_test( command } ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_enbrighten_metering_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_enbrighten_metering_dimmer.lua index 3b5a5b93b1..86d654a053 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_enbrighten_metering_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_enbrighten_metering_dimmer.lua @@ -128,7 +128,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -152,6 +155,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOffCluster.server.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -176,6 +182,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOffCluster.server.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -203,6 +212,9 @@ test.register_message_test( LevelCluster.server.commands.MoveToLevelWithOnOff(mock_device, math.floor(57 * 254 / 100)) } } + }, + { + min_api_version = 14 } ) @@ -230,6 +242,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -246,6 +261,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 33.3, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -262,6 +280,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.5555, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_firstled_light.lua b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_light.lua new file mode 100644 index 0000000000..05a04a06ad --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_light.lua @@ -0,0 +1,106 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local PRIVATE_CLUSTER_ID = 0xFC00 + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("light-color-temp-time-restore.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "FIRSTLED", + model = "DC2DC12MiV1", + server_clusters = { 0x0006, 0x0008, 0x0300 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +-- ====================== Preferences ====================== +test.register_coroutine_test("infoChanged - outputMode 0", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { outputMode = "0" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0000), + data_types.validate_or_build_type(0, data_types.Uint8, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - powerOnMode 0", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { powerOnMode = "0" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0001), + data_types.validate_or_build_type(0, data_types.Uint8, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - ledDriveCurrent 100", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { ledDriveCurrent = "100" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0002), + data_types.validate_or_build_type(100, data_types.Uint16, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - dimTransitionTime 2000", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { dimTransitionTime = "2000" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0003), + data_types.validate_or_build_type(2000, data_types.Uint16, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("infoChanged - colorTempTransitionTime 2000", function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ preferences = { colorTempTransitionTime = "2000" }})) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_attribute(mock_device, + data_types.ClusterId(PRIVATE_CLUSTER_ID), + data_types.AttributeId(0x0004), + data_types.validate_or_build_type(2000, data_types.Uint16, "payload")) + }) + end, + { + min_api_version = 19 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua new file mode 100644 index 0000000000..a9bc2ac8fe --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_firstled_switch.lua @@ -0,0 +1,481 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local frameCtrl = require "st.zigbee.zcl.frame_ctrl" +local device_lib = require "st.device" + +local OnOff = clusters.OnOff +local Scenes = clusters.Scenes + +local PRIVATE_CLUSTER_ID = 0xFCCA +local MFG_CODE = 0x1235 +local FINGERPRINTS = require("firstled-io.fingerprints") + +local parent_profile = t_utils.get_profile_definition("switch-button-light-restore-wireless.yml") +local child_switch_profile = t_utils.get_profile_definition("switch-button-wireless.yml") + +local function get_children_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.children + end + end +end + +local function get_button_amount(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.buttons + end + end +end + +local function get_child_profile_name(device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_model() == fingerprint.model then + return fingerprint.child_profile + end + end +end + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end +-- ====================== Mock Devices ====================== +local mock_parent = test.mock_device.build_test_zigbee_device({ + profile = parent_profile, + manufacturer = "FIRSTLED", + model = "M4S4BAC", + label = "Mirror Series 4x4 1", + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { id = 1, manufacturer = "FIRSTLED", model = "M4S4BAC", server_clusters = { 0x0004, 0x0006 } } + } +}) + +local mock_children = {} + +for i = 2, 4 do + local name = string.format("%s%d", string.sub("Mirror Series 4x4 1", 0, -2), i) + table.insert(mock_children, test.mock_device.build_test_child_device({ + type = "EDGE_CHILD", + profile = child_switch_profile, + label = name, + device_network_id = string.format("%04X:%02X", mock_parent:get_short_address(), i), + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + vendor_provided_label = name + })) +end + +local function test_init() + test.mock_device.add_test_device(mock_parent) + for _, child in ipairs(mock_children) do + test.mock_device.add_test_device(child) + end +end + +test.set_test_init_function(test_init) + +-- ====================== can_handle ====================== +test.register_coroutine_test("can_handle should return true and handler for matching device", function() + local can_handle = require("firstled-io.can_handle") + local result, handler = can_handle({}, nil, mock_parent) + assert(result == true) + assert(handler ~= nil) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test("can_handle should return false for non-matching device", function() + local can_handle = require("firstled-io.can_handle") + local non_match = test.mock_device.build_test_zigbee_device({ + manufacturer = "OTHER", model = "OTHER", profile = parent_profile + }) + local result = can_handle({}, nil, non_match) + assert(result == false) + end, + { + min_api_version = 19 + } +) + +-- ====================== Lifecycle ====================== +test.register_coroutine_test("device_init should set find_child for parent", function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + end, + { + min_api_version = 19 + } +) + +-- ====================== device_added ====================== +test.register_coroutine_test("device_added - Zigbee Parent should create children and emit capabilities", function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "added"}) + if mock_parent.network_type == device_lib.NETWORK_TYPE_ZIGBEE then + local children_amount = get_children_amount(mock_parent) + if children_amount >= 2 then + for i = 2, children_amount, 1 do + if find_child(mock_parent, i) == nil then + local name = string.format("%s%d", string.sub(mock_parent.label, 0, -2), i) + local expected_metadata = { + type = "EDGE_CHILD", + label = name, + profile = get_child_profile_name(mock_parent), + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + } + mock_parent:expect_device_create(expected_metadata) + end + end + end + local button_amount = get_button_amount(mock_parent) + if button_amount >= 1 then + for i = children_amount + 1, children_amount + button_amount, 1 do + if find_child(mock_parent, i) == nil then + local name = string.format("%s%d", string.sub(mock_parent.label, 0, -2), i) + local expected_metadata = { + type = "EDGE_CHILD", + label = name, + profile = "button", + parent_device_id = mock_parent.id, + parent_assigned_child_key = string.format("%02X", i), + } + mock_parent:expect_device_create(expected_metadata) + end + end + end + + elseif mock_parent.network_type == "DEVICE_EDGE_CHILD" then + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + end + end, + { + min_api_version = 19 + } +) + +local function test_device_added_child(ep, name) + test.register_coroutine_test(name, function() + local child = mock_children[ep-1] + test.socket.device_lifecycle:__queue_receive({child.id, "added"}) + + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + + test.socket.capability:__expect_send(child:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + end, + { + min_api_version = 19 + } +) +end + +for ep = 2, 4 do + test_device_added_child(ep, "test_device_added_child endpoint " .. ep) +end + +-- ====================== Preferences ====================== +test.register_coroutine_test("infoChanged - backlight 0", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "0" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test("infoChanged - backlight 1", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "1" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test("infoChanged - backlight 2", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { backlight = "2" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus 0", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "0" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 0) + }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus 1", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "1" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 1) + }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test("infoChanged - powerOnStatus 2", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { powerOnStatus = "2" }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0001, MFG_CODE, data_types.Uint8, 2) + }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test("infoChanged - stse.changeToWirelessSwitch true", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = true }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, true) + }) + mock_parent:expect_metadata_update({ profile = "switch-button-light-restore-wireless" }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test("infoChanged - stse.changeToWirelessSwitch false", function() + test.socket.device_lifecycle:__queue_receive(mock_parent:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = false }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, false) + }) + mock_parent:expect_metadata_update({ profile = "switch-light-restore-wireless" }) + end, + { + min_api_version = 20 + } +) + +local function test_child_changeToWirelessSwitch_true(ep, name) + test.register_coroutine_test(name, function() + test.socket.device_lifecycle:__queue_receive(mock_children[ep]:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = true }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, true):to_endpoint(ep+1) + }) + mock_children[ep]:expect_metadata_update({ profile = "switch-button-wireless" }) + end, + { + min_api_version = 20 + } + ) +end + +local function test_child_changeToWirelessSwitch_false(ep, name) + test.register_coroutine_test(name, function() + test.socket.device_lifecycle:__queue_receive(mock_children[ep]:generate_info_changed({ preferences = { ["stse.changeToWirelessSwitch"] = false }})) + test.socket.zigbee:__expect_send({ mock_parent.id, + cluster_base.write_manufacturer_specific_attribute(mock_parent, PRIVATE_CLUSTER_ID, 0x0002, MFG_CODE, data_types.Boolean, false):to_endpoint(ep+1) + }) + mock_children[ep]:expect_metadata_update({ profile = "switch-wireless" }) + end, + { + min_api_version = 20 + } + ) +end + +for ep = 1, 3 do + test_child_changeToWirelessSwitch_true(ep, "children infoChanged - stse.changeToWirelessSwitch true " .. ep + 1) +end + +for ep = 1, 3 do + test_child_changeToWirelessSwitch_false(ep, "children infoChanged - stse.changeToWirelessSwitch false " .. ep + 1) +end + +-- ====================== Commands ====================== +test.register_message_test("Parent device - On command", { + { channel = "device_lifecycle", direction = "receive", message = { mock_parent.id, "init" }}, + { channel = "capability", direction = "receive", message = { mock_parent.id, { capability = "switch", component = "main", command = "on", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_parent.id, capability_id = "switch", capability_cmd_id = "on" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.On(mock_parent):to_endpoint(0x01) }} + }, + { + min_api_version = 19 + } +) + +test.register_message_test("Parent device - Off command", { + { channel = "capability", direction = "receive", message = { mock_parent.id, { capability = "switch", component = "main", command = "off", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_parent.id, capability_id = "switch", capability_cmd_id = "off" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.Off(mock_parent):to_endpoint(0x01) }} + }, + { + min_api_version = 19 + } +) + +-- ====================== Attribute Reports ====================== +test.register_coroutine_test( + "OnOff report on parent endpoint", + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, true):from_endpoint(0x01) + test.socket.zigbee:__queue_receive({mock_parent.id, report}) + + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", capabilities.switch.switch.on())) + mock_parent:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "OnOff report off parent endpoint", + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, false):from_endpoint(0x01) + test.socket.zigbee:__queue_receive({mock_parent.id, report}) + + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", capabilities.switch.switch.off())) + mock_parent:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } +) + +local function test_on_cmd(ep, name) + test.register_message_test(name, { + { channel = "capability", direction = "receive", message = { mock_children[ep].id, { capability = "switch", component = "main", command = "on", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_children[ep].id, capability_id = "switch", capability_cmd_id = "on" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.On(mock_parent):to_endpoint(ep+1) }} + }, + { + min_api_version = 19 + }) +end + +local function test_off_cmd(ep, name) + test.register_message_test(name, { + { channel = "capability", direction = "receive", message = { mock_children[ep].id, { capability = "switch", component = "main", command = "off", args = {} }}}, + { channel = "devices", direction = "send", message = { "register_native_capability_cmd_handler", { device_uuid = mock_children[ep].id, capability_id = "switch", capability_cmd_id = "off" }}}, + { channel = "zigbee", direction = "send", message = { mock_parent.id, OnOff.server.commands.Off(mock_parent):to_endpoint(ep+1) }} + }, + { + min_api_version = 19 + }) +end + +local function test_onoff_report_on_cmd(ep, name) + test.register_coroutine_test( + name, + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, true):from_endpoint(ep+1) + test.socket.zigbee:__queue_receive({mock_children[ep].id, report}) + + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", capabilities.switch.switch.on())) + mock_children[ep]:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } + ) +end + +local function test_onoff_report_off_cmd(ep, name) + test.register_coroutine_test( + name, + function() + test.socket.device_lifecycle:__queue_receive({mock_parent.id, "init"}) + + local report = OnOff.attributes.OnOff:build_test_attr_report(mock_parent, false):from_endpoint(ep+1) + test.socket.zigbee:__queue_receive({mock_children[ep].id, report}) + + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", capabilities.switch.switch.off())) + mock_children[ep]:expect_native_attr_handler_registration("switch", "switch") + end, + { + min_api_version = 19 + } + ) +end + +-- ====================== RecallScene ====================== +local function test_recall_scene(ep, name) + test.register_coroutine_test(name, function() + local cmd = Scenes.server.commands.RecallScene.build_test_rx(mock_parent, 0xF0F0, ep) + cmd.body.zcl_header.frame_ctrl = frameCtrl(0x11) + test.socket.zigbee:__queue_receive({ mock_parent.id, cmd }) + test.socket.capability:__expect_send(mock_parent:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 19 + }) +end + +local function test_child_recall_scene(ep, name) + test.register_coroutine_test(name, function() + local cmd = Scenes.server.commands.RecallScene.build_test_rx(mock_parent, 0xF0F0, ep + 1) + cmd.body.zcl_header.frame_ctrl = frameCtrl(0x11) + test.socket.zigbee:__queue_receive({ mock_children[ep].id, cmd }) + test.socket.capability:__expect_send(mock_children[ep]:generate_test_message("main", + capabilities.button.button.pushed({ state_change = true }))) + end, + { + min_api_version = 19 + }) +end + +for ep = 1, 1 do + test_recall_scene(ep, "RecallScene on parent endpoint " .. ep) +end + +for ep = 1, 3 do + test_child_recall_scene(ep, "test_child_recall_scene on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_on_cmd(ep, "children test_on_cmd on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_off_cmd(ep, "children test_off_cmd on endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_onoff_report_on_cmd(ep, "children test_onoff_report_on_cmd endpoint " .. ep + 1) +end + +for ep = 1, 3 do + test_onoff_report_off_cmd(ep, "children test_onoff_report_off_cmd endpoint " .. ep + 1) +end + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua new file mode 100644 index 0000000000..f1e568bc7f --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_IO_module.lua @@ -0,0 +1,698 @@ +-- Copyright 2025 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local data_types = require "st.zigbee.data_types" +local cluster_base = require "st.zigbee.cluster_base" +local messages = require "st.zigbee.messages" +local constants = require "st.zigbee.constants" +local zdo_messages = require "st.zigbee.zdo" +local bind_request = require "st.zigbee.zdo.bind_request" +local unbind_request = require "frient-IO.unbind_request" +local default_response = require "st.zigbee.zcl.global_commands.default_response" +local zcl_messages = require "st.zigbee.zcl" +local Status = require "st.zigbee.generated.types.ZclStatus" +local device_management = require "st.zigbee.device_management" +local configuration_map = require "configurations" +local switch_defaults = require "st.zigbee.defaults.switch_defaults" +local mock_devices_api = require "integration_test.mock_devices_api" + +local BasicInput = clusters.BasicInput +local OnOff = clusters.OnOff +local Switch = capabilities.switch + +local ZIGBEE_ENDPOINTS = { + INPUT_1 = 0x70, + INPUT_2 = 0x71, + INPUT_3 = 0x72, + INPUT_4 = 0x73, + OUTPUT_1 = 0x74, + OUTPUT_2 = 0x75, +} + +local INPUT_CONFIGS = { + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_1, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_2, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_3, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, + { + endpoint = ZIGBEE_ENDPOINTS.INPUT_4, + binds = { + ZIGBEE_ENDPOINTS.OUTPUT_1, + ZIGBEE_ENDPOINTS.OUTPUT_2, + }, + }, +} + +local DEVELCO_MFG_CODE = 0x1015 +local ON_TIME_ATTR = 0x8000 +local OFF_WAIT_ATTR = 0x8001 + +local function sanitize_timing(value) + local v = tonumber(value) or 0 + if v < 0 then + v = 0 + elseif v > 0xFFFF then + v = 0xFFFF + end + return math.tointeger(v) or 0 +end + +local function to_deciseconds(value) + return math.floor(sanitize_timing(value) * 10) +end + +local function build_client_mfg_write(device, endpoint, attr_id, value) + local msg = cluster_base.write_manufacturer_specific_attribute( + device, + BasicInput.ID, + attr_id, + DEVELCO_MFG_CODE, + data_types.Uint16, + value + ) + msg.body.zcl_header.frame_ctrl:set_direction_client() + msg.tx_options = data_types.Uint16(0) + return msg:to_endpoint(endpoint) +end + +local function build_basic_input_polarity_write(device, endpoint, enabled) + local polarity_value = data_types.validate_or_build_type( + enabled and 1 or 0, + BasicInput.attributes.Polarity.base_type, + "payload" + ) + local msg = cluster_base.write_attribute( + device, + data_types.ClusterId(BasicInput.ID), + data_types.AttributeId(BasicInput.attributes.Polarity.ID), + polarity_value + ) + msg.tx_options = data_types.Uint16(0) + return msg:to_endpoint(endpoint) +end + +local function build_bind(device, src_ep, dest_ep) + local addr_header = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device.fingerprinted_endpoint_id, + constants.ZDO_PROFILE_ID, + bind_request.BindRequest.ID + ) + local bind_body = bind_request.BindRequest( + device.zigbee_eui, + src_ep, + BasicInput.ID, + bind_request.ADDRESS_MODE_64_BIT, + device.zigbee_eui, + dest_ep + ) + local message_body = zdo_messages.ZdoMessageBody({ zdo_body = bind_body }) + local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + msg.tx_options = data_types.Uint16(0) + return msg +end + +local function build_unbind(device, src_ep, dest_ep) + local addr_header = messages.AddressHeader( + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + device:get_short_address(), + device.fingerprinted_endpoint_id, + constants.ZDO_PROFILE_ID, + unbind_request.UNBIND_REQUEST_CLUSTER_ID + ) + local unbind_body = unbind_request.UnbindRequest( + device.zigbee_eui, + src_ep, + BasicInput.ID, + unbind_request.ADDRESS_MODE_64_BIT, + device.zigbee_eui, + dest_ep + ) + local message_body = zdo_messages.ZdoMessageBody({ zdo_body = unbind_body }) + local msg = messages.ZigbeeMessageTx({ address_header = addr_header, body = message_body }) + msg.tx_options = data_types.Uint16(0) + return msg +end + +local function build_default_response_msg(device, endpoint, command_id) + local addr_header = messages.AddressHeader( + device:get_short_address(), + endpoint, + constants.HUB.ADDR, + constants.HUB.ENDPOINT, + constants.HA_PROFILE_ID, + OnOff.ID + ) + local response_body = default_response.DefaultResponse(command_id, Status.SUCCESS) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(response_body.ID) + }) + local message_body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = response_body + }) + return messages.ZigbeeMessageRx({ address_header = addr_header, body = message_body }) +end + +local function build_output_timing(device, child, suffix) + local on_pref + local off_pref + if child.preferences.configOnTime ~= nil or child.preferences.configOffWaitTime ~= nil then + on_pref = child.preferences.configOnTime or 0 + off_pref = child.preferences.configOffWaitTime or 0 + else + on_pref = device.preferences["configOnTime" .. suffix] or 0 + off_pref = device.preferences["configOffWaitTime" .. suffix] or 0 + end + return to_deciseconds(on_pref), to_deciseconds(off_pref) +end + +local function copy_table(source) + local result = {} + for key, value in pairs(source) do + result[key] = value + end + return result +end + +local parent_preference_state = {} + +local mock_parent_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("switch-4inputs-2outputs.yml"), + fingerprinted_endpoint_id = ZIGBEE_ENDPOINTS.INPUT_1, + label = "frient IO Module", + zigbee_endpoints = { + [ZIGBEE_ENDPOINTS.INPUT_1] = { + id = ZIGBEE_ENDPOINTS.INPUT_1, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_2] = { + id = ZIGBEE_ENDPOINTS.INPUT_2, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_3] = { + id = ZIGBEE_ENDPOINTS.INPUT_3, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.INPUT_4] = { + id = ZIGBEE_ENDPOINTS.INPUT_4, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.OUTPUT_1] = { + id = ZIGBEE_ENDPOINTS.OUTPUT_1, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { OnOff.ID, BasicInput.ID }, + }, + [ZIGBEE_ENDPOINTS.OUTPUT_2] = { + id = ZIGBEE_ENDPOINTS.OUTPUT_2, + manufacturer = "frient A/S", + model = "IOMZB-110", + server_clusters = { OnOff.ID, BasicInput.ID }, + }, + }, +}) + + function mock_parent_device:get_model() + return "IOMZB-110" + end + + function mock_parent_device:get_manufacturer() + return "frient A/S" + end + + function mock_parent_device:supports_server_cluster(cluster_id, endpoint_id) + local function endpoint_supports(ep) + if not ep or not ep.server_clusters then return false end + for _, server_cluster in ipairs(ep.server_clusters) do + if server_cluster == cluster_id then + return true + end + end + return false + end + + if endpoint_id ~= nil then + return endpoint_supports(self.zigbee_endpoints[endpoint_id]) + end + + for _, endpoint in pairs(self.zigbee_endpoints) do + if endpoint_supports(endpoint) then + return true + end + end + return false + end + +local mock_output_child_1 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "frient-io-output-1", + label = "frient IO Module Output 1", + vendor_provided_label = "Output 1", +}) + +local mock_output_child_2 = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("frient-io-output-switch.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "frient-io-output-2", + label = "frient IO Module Output 2", + vendor_provided_label = "Output 2", +}) + +local function reset_preferences() + mock_parent_device.preferences.reversePolarity1 = false + mock_parent_device.preferences.reversePolarity2 = false + mock_parent_device.preferences.reversePolarity3 = false + mock_parent_device.preferences.reversePolarity4 = false + + mock_parent_device.preferences.controlOutput11 = false + mock_parent_device.preferences.controlOutput21 = false + mock_parent_device.preferences.controlOutput12 = false + mock_parent_device.preferences.controlOutput22 = false + mock_parent_device.preferences.controlOutput13 = false + mock_parent_device.preferences.controlOutput23 = false + mock_parent_device.preferences.controlOutput14 = false + mock_parent_device.preferences.controlOutput24 = false + + mock_parent_device.preferences.configOnTime1 = 3 + mock_parent_device.preferences.configOffWaitTime1 = 4 + mock_parent_device.preferences.configOnTime2 = 7 + mock_parent_device.preferences.configOffWaitTime2 = 8 + + mock_output_child_1.preferences.configOnTime = 5 + mock_output_child_1.preferences.configOffWaitTime = 6 + mock_output_child_2.preferences.configOnTime = 0 + mock_output_child_2.preferences.configOffWaitTime = 0 + + parent_preference_state = copy_table(mock_parent_device.preferences) + + local field_keys = { + "frient_io_native_70", + "frient_io_native_71", + "frient_io_native_72", + "frient_io_native_73", + "frient_io_native_74", + "frient_io_native_75", + } + + for _, key in ipairs(field_keys) do + mock_parent_device:set_field(key, nil, { persist = true }) + end + + mock_output_child_1:set_field("frient_io_native_74", nil, { persist = true }) + mock_output_child_2:set_field("frient_io_native_75", nil, { persist = true }) +end + +local function queue_child_info_changed(child, preferences) + local raw = rawget(child, "raw_st_data") + if raw and raw.preferences then + for key, value in pairs(preferences) do + raw.preferences[key] = value + end + end + test.socket.device_lifecycle:__queue_receive(child:generate_info_changed({ preferences = preferences })) +end + +local function queue_parent_info_changed(preferences) + local full_preferences = copy_table(parent_preference_state) + for key, value in pairs(preferences) do + full_preferences[key] = value + end + parent_preference_state = copy_table(full_preferences) + + local raw = rawget(mock_parent_device, "raw_st_data") + if raw and raw.preferences then + for key, value in pairs(full_preferences) do + raw.preferences[key] = value + end + end + + test.socket.device_lifecycle:__queue_receive( + mock_parent_device:generate_info_changed({ preferences = full_preferences }) + ) +end + +local function register_initial_config_expectations() + if test.socket.zigbee and test.socket.zigbee.__set_channel_ordering then + test.socket.zigbee:__set_channel_ordering("relaxed") + end + if test.socket.devices and test.socket.devices.__set_channel_ordering then + test.socket.devices:__set_channel_ordering("relaxed") + end + + local function register_device_configure_expectations() + local configuration = configuration_map.get_device_configuration(mock_parent_device) or {} + local configs_by_cluster = {} + local function add_attribute_config(attribute) + if attribute.configurable ~= false then + configs_by_cluster[attribute.cluster] = configs_by_cluster[attribute.cluster] or {} + table.insert(configs_by_cluster[attribute.cluster], attribute) + end + end + + for _, attribute in ipairs(configuration) do + add_attribute_config(attribute) + end + + local default_configs = switch_defaults.attribute_configurations or {} + for _, attribute in ipairs(default_configs) do + add_attribute_config(attribute) + end + + local cluster_ids = {} + for cluster_id in pairs(configs_by_cluster) do + cluster_ids[#cluster_ids + 1] = cluster_id + end + table.sort(cluster_ids) + + local endpoint_ids = {} + for endpoint_id in pairs(mock_parent_device.zigbee_endpoints) do + endpoint_ids[#endpoint_ids + 1] = endpoint_id + end + table.sort(endpoint_ids) + + for _, cluster_id in ipairs(cluster_ids) do + local attr_configs = configs_by_cluster[cluster_id] + table.sort(attr_configs, function(a, b) + return a.attribute < b.attribute + end) + for _, endpoint_id in ipairs(endpoint_ids) do + local endpoint = mock_parent_device.zigbee_endpoints[endpoint_id] + if endpoint and mock_parent_device:supports_server_cluster(cluster_id, endpoint.id) then + local bind_cmd = device_management.build_bind_request( + mock_parent_device, + cluster_id, + zigbee_test_utils.mock_hub_eui, + endpoint.id + ):to_endpoint(endpoint.id) + bind_cmd.tx_options = data_types.Uint16(0) + test.socket.zigbee:__expect_send({ mock_parent_device.id, bind_cmd }) + for _, attr_config in ipairs(attr_configs) do + local config_cmd = device_management.attr_config(mock_parent_device, attr_config):to_endpoint(endpoint.id) + config_cmd.tx_options = data_types.Uint16(0) + test.socket.zigbee:__expect_send({ mock_parent_device.id, config_cmd }) + end + end + end + end + end + + register_device_configure_expectations() + + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") + local on2, off2 = build_output_timing(mock_parent_device, mock_output_child_2, "2") + + local function enqueue_output_timing_writes() + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, on1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, off1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, ON_TIME_ATTR, on2) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_2, OFF_WAIT_ATTR, off2) }) + end + + -- Device init issues one set of manufacturer-specific writes per output during startup + enqueue_output_timing_writes() + + for _, config in ipairs(INPUT_CONFIGS) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_basic_input_polarity_write(mock_parent_device, config.endpoint, false) }) + for _, output_ep in ipairs(config.binds) do + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, config.endpoint, output_ep) }) + end + end +end + +local function expect_init_sequence() + mock_devices_api.__expect_update_device( + mock_parent_device.id, + { deviceId = mock_parent_device.id, provisioningState = "PROVISIONED" } + ) + test.socket.device_lifecycle:__queue_receive({ mock_parent_device.id, "doConfigure" }) +end + +local function expect_switch_registration(device) + test.socket.devices:__expect_send({ + "register_native_capability_attr_handler", + { device_uuid = device.id, capability_id = "switch", capability_attr_id = "switch" }, + }) +end + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + reset_preferences() + register_initial_config_expectations() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_output_child_1) + test.mock_device.add_test_device(mock_output_child_2) + zigbee_test_utils.init_noop_health_check_timer() + --register_initial_config_expectations() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Init configures outputs and routes attribute reports", + function() + expect_init_sequence() + test.wait_for_events() + + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1), + }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) + expect_switch_registration(mock_output_child_1) + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_parent_device, false):from_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2), + }) + test.socket.capability:__expect_send(mock_output_child_2:generate_test_message("main", Switch.switch.off())) + expect_switch_registration(mock_output_child_2) + + test.socket.zigbee:__queue_receive({ + mock_parent_device.id, + BasicInput.attributes.PresentValue:build_test_attr_report(mock_parent_device, true):from_endpoint(ZIGBEE_ENDPOINTS.INPUT_3), + }) + test.socket.capability:__expect_send(mock_parent_device:generate_test_message("input3", Switch.switch.on())) + + test.wait_for_events() + + local child1_native = mock_output_child_1:get_field("frient_io_native_74") + assert(child1_native, "expected Output 1 child to register native switch handler") + local child2_native = mock_output_child_2:get_field("frient_io_native_75") + assert(child2_native, "expected Output 2 child to register native switch handler") + local parent_native = mock_parent_device:get_field("frient_io_native_72") + assert(parent_native, "expected parent device to register native switch handler for input 3") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Default responses update state and trigger reads", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + + local on_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.On.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, on_response }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.on())) + + local timed_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.OnWithTimedOff.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, timed_response }) + local read_msg = cluster_base.read_attribute( + mock_parent_device, + data_types.ClusterId(OnOff.ID), + data_types.AttributeId(OnOff.attributes.OnOff.ID) + ) + read_msg.tx_options = data_types.Uint16(0) + read_msg = read_msg:to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, read_msg }) + + local off_response = build_default_response_msg(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OnOff.server.commands.Off.ID) + test.socket.zigbee:__queue_receive({ mock_parent_device.id, off_response }) + test.socket.capability:__expect_send(mock_output_child_1:generate_test_message("main", Switch.switch.off())) + + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Switch commands drive the correct Zigbee commands", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ + mock_output_child_1.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + local on1, off1 = build_output_timing(mock_parent_device, mock_output_child_1, "1") + local timed_on = OnOff.server.commands.OnWithTimedOff( + mock_parent_device, + data_types.Uint8(0), + data_types.Uint16(on1), + data_types.Uint16(off1) + ):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) + + test.socket.capability:__queue_receive({ + mock_output_child_1.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + local direct_off_output1 = OnOff.server.commands.Off(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_1) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off_output1 }) + + test.socket.capability:__queue_receive({ + mock_output_child_2.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + local direct_on = OnOff.server.commands.On(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_on }) + + test.socket.capability:__queue_receive({ + mock_output_child_2.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + local direct_off = OnOff.server.commands.Off(mock_parent_device):to_endpoint(ZIGBEE_ENDPOINTS.OUTPUT_2) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) + + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", component = "output1", command = "on", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, timed_on }) + + test.socket.capability:__queue_receive({ + mock_parent_device.id, + { capability = "switch", component = "output2", command = "off", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, direct_off }) + + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Child preference changes send manufacturer writes", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + queue_child_info_changed(mock_output_child_1, { configOnTime = 12, configOffWaitTime = 13 }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, ON_TIME_ATTR, to_deciseconds(12)), + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_client_mfg_write(mock_parent_device, ZIGBEE_ENDPOINTS.OUTPUT_1, OFF_WAIT_ATTR, to_deciseconds(13)), + }) + + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Parent preference changes manage polarity and binds", + function() + expect_init_sequence() + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + queue_parent_info_changed({ + reversePolarity1 = true, + controlOutput11 = true, + controlOutput21 = true, + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, true), + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + test.wait_for_events() + + queue_parent_info_changed({ + reversePolarity1 = true, + controlOutput11 = false, + controlOutput21 = true, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_1, ZIGBEE_ENDPOINTS.OUTPUT_1) }) + test.wait_for_events() + + queue_parent_info_changed({ + reversePolarity3 = true, + controlOutput23 = true, + }) + test.socket.zigbee:__expect_send({ + mock_parent_device.id, + build_basic_input_polarity_write(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, true), + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_bind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + test.wait_for_events() + + queue_parent_info_changed({ + reversePolarity3 = true, + controlOutput23 = false, + }) + test.socket.zigbee:__expect_send({ mock_parent_device.id, build_unbind(mock_parent_device, ZIGBEE_ENDPOINTS.INPUT_3, ZIGBEE_ENDPOINTS.OUTPUT_2) }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_frient_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_frient_switch.lua index 4290d6ebbe..90cd1524bd 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_frient_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_frient_switch.lua @@ -79,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 14 } ) @@ -113,7 +116,11 @@ test.register_message_test("Current divisor, multiplier, summation should be han direction = "send", message = mock_device:generate_test_message("main", capabilities.currentMeasurement.current({ value = 200.0, unit = "A" })) }, - }) + }, + { + min_api_version = 14 + } +) test.register_coroutine_test("Refresh command should read all necessary attributes", function() test.socket.zigbee:__set_channel_ordering("relaxed") @@ -144,7 +151,11 @@ test.register_coroutine_test("Refresh command should read all necessary attribut test.socket.zigbee:__expect_send( {mock_device.id, OnOff.attributes.OnOff:read(mock_device) } ) -end) +end, +{ + min_api_version = 14 +} +) test.register_message_test( "Handle switch ON report", @@ -167,6 +178,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -191,6 +205,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -220,6 +237,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -242,6 +262,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.027, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -252,7 +275,10 @@ test.register_coroutine_test( assert(mock_device:get_field(constants.SIMPLE_METERING_DIVISOR_KEY) == 1000) assert(mock_device:get_field(constants.ELECTRICAL_MEASUREMENT_DIVISOR_KEY) == 1000) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test("doConfigure should send bind request, read attributes and configure reporting", function() @@ -313,7 +339,11 @@ test.register_coroutine_test("doConfigure should send bind request, read attribu test.socket.zigbee:__expect_send({mock_device.id, Alarms.attributes.AlarmCount:read(mock_device)}) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test( "Alarm report should be handled", @@ -325,7 +355,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown())) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_ge_link_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_ge_link_bulb.lua index d37dafe45d..94104f74b7 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_ge_link_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_ge_link_bulb.lua @@ -45,7 +45,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -59,7 +62,10 @@ test.register_coroutine_test( } test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 50) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -71,7 +77,10 @@ test.register_coroutine_test( } } test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +110,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -135,7 +147,50 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Handle infoChanged when dimOnOff changes from 1 to 0 should write transition time 0", + function() + -- First: change dimOnOff from default 0 to 1 (triggers write with dimRate=20) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { dimOnOff = 1 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 20) }) + test.wait_for_events() + -- Now: change dimOnOff from 1 to 0 (driver old=1, new=0 -> writes 0) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { dimOnOff = 0 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 0) }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Handle infoChanged when dimRate changes while dimOnOff is 1 should write new dimRate", + function() + -- First: change dimOnOff from default 0 to 1 (triggers write with dimRate=20) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { dimOnOff = 1 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 20) }) + test.wait_for_events() + -- Now: change dimRate while dimOnOff stays 1 (driver old={dimOnOff=1,dimRate=20}, new={dimOnOff=1,dimRate=50}) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { dimOnOff = 1, dimRate = 50 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnOffTransitionTime:write(mock_device, 50) }) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_hanssem_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_hanssem_switch.lua index 96f4581310..13184c37d3 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_hanssem_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_hanssem_switch.lua @@ -126,6 +126,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -156,6 +159,9 @@ test.register_message_test( { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -186,6 +192,9 @@ test.register_message_test( { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -216,6 +225,9 @@ test.register_message_test( { device_uuid = mock_third_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -246,6 +258,9 @@ test.register_message_test( { device_uuid = mock_fourth_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -276,6 +291,9 @@ test.register_message_test( { device_uuid = mock_fifth_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -306,6 +324,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -336,6 +357,9 @@ test.register_message_test( { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -366,6 +390,9 @@ test.register_message_test( { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -396,6 +423,9 @@ test.register_message_test( { device_uuid = mock_third_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -426,6 +456,9 @@ test.register_message_test( { device_uuid = mock_fourth_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -456,6 +489,9 @@ test.register_message_test( { device_uuid = mock_fifth_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -485,6 +521,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 14 } ) @@ -509,6 +548,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -533,6 +575,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) @@ -557,6 +602,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x04) } } + }, + { + min_api_version = 14 } ) @@ -581,6 +629,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x05) } } + }, + { + min_api_version = 14 } ) @@ -605,6 +656,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x06) } } + }, + { + min_api_version = 14 } ) @@ -629,6 +683,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 14 } ) @@ -653,6 +710,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -677,6 +737,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) @@ -701,6 +764,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x04) } } + }, + { + min_api_version = 14 } ) @@ -725,6 +791,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x05) } } + }, + { + min_api_version = 14 } ) @@ -749,6 +818,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x06) } } + }, + { + min_api_version = 14 } ) @@ -796,7 +868,11 @@ test.register_coroutine_test( mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(0x01) }) - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn.lua index 79e7a66b54..3dcee92797 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn.lua @@ -104,7 +104,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -142,7 +143,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -165,7 +167,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -188,7 +191,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -214,7 +218,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -283,7 +288,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -314,7 +320,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -350,7 +357,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm30_sn:generate_test_message("button1", capabilities.button.button.pushed_2x({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) -- Test temperature measurement @@ -370,6 +380,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_vzm30_sn:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 25.0, unit = "C"})) } + }, + { + min_api_version = 14 } ) @@ -390,6 +403,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_vzm30_sn:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity(65)) } + }, + { + min_api_version = 14 } ) @@ -409,7 +425,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm30_sn:generate_test_message("main", capabilities.powerMeter.power({value = 200.0, unit = "W"})) ) - end + end, + { + min_api_version = 14 + } ) -- Test energy meter @@ -427,7 +446,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm30_sn:generate_test_message("main", capabilities.energyMeter.energy({value = 0.212, unit = "kWh"})) ) - end + end, + { + min_api_version = 14 + } ) -- Test energy meter reset command @@ -474,7 +496,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -531,7 +554,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_inovelli_vzm30_sn.id, RelativeHumidity.attributes.MeasuredValue:configure_reporting(mock_inovelli_vzm30_sn, 30, 3600, 50) }) mock_inovelli_vzm30_sn:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_child.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_child.lua index e40ead721a..8be1b0ee0a 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_child.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_child.lua @@ -90,7 +90,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -143,7 +144,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device switch off command @@ -174,7 +178,10 @@ test.register_coroutine_test( utils.serialize_int(0, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device level command @@ -230,7 +237,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device color command @@ -290,7 +300,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device color temperature command @@ -350,7 +363,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_preferences.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_preferences.lua index 0726a2d575..fa6bf5202f 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_preferences.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm30_sn_preferences.lua @@ -61,7 +61,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter9 preference change @@ -83,7 +86,10 @@ test.register_coroutine_test( expected_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter52 preference change @@ -104,7 +110,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter258 preference change @@ -125,7 +134,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter11 preference change (VZM30-only, same as VZM31) @@ -146,7 +158,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter17 preference change (VZM30-only, same as VZM31) @@ -167,7 +182,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter22 preference change (VZM30-only, same as VZM31) @@ -188,7 +206,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test notificationChild preference change @@ -204,7 +225,10 @@ test.register_coroutine_test( }) test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm30_sn:generate_info_changed({preferences = {notificationChild = true}})) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn.lua index a3d57415b1..1549b80a41 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn.lua @@ -72,7 +72,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -95,7 +96,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -118,7 +120,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -144,7 +147,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -213,7 +217,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -244,7 +249,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -280,7 +286,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm31_sn:generate_test_message("button1", capabilities.button.button.pushed_2x({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) -- Test power meter from ElectricalMeasurement @@ -299,7 +308,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm31_sn:generate_test_message("main", capabilities.powerMeter.power({value = 200.0, unit = "W"})) ) - end + end, + { + min_api_version = 14 + } ) -- Test energy meter @@ -318,7 +330,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm31_sn:generate_test_message("main", capabilities.energyMeter.energy({value = 500.0, unit = "kWh"})) ) - end + end, + { + min_api_version = 14 + } ) -- Test energy meter reset command @@ -365,7 +380,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -415,7 +431,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_inovelli_vzm31_sn.id, clusters.ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_inovelli_vzm31_sn) }) mock_inovelli_vzm31_sn:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_child.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_child.lua index c6476cba5e..a3b1717c04 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_child.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_child.lua @@ -79,7 +79,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -132,7 +133,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device switch off command @@ -163,7 +167,10 @@ test.register_coroutine_test( utils.serialize_int(0, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device level command @@ -219,7 +226,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device color command @@ -279,7 +289,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device color temperature command @@ -339,7 +352,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_preferences.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_preferences.lua index 64b0d51ee5..56f526c2cd 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_preferences.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm31_sn_preferences.lua @@ -50,7 +50,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter9 preference change @@ -72,7 +75,10 @@ test.register_coroutine_test( expected_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter52 preference change @@ -93,7 +99,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter258 preference change @@ -114,7 +123,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter11 preference change (VZM31-only) @@ -135,7 +147,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter17 preference change (VZM31-only) @@ -156,7 +171,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter22 preference change (VZM31-only) @@ -177,7 +195,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test notificationChild preference change @@ -193,7 +214,10 @@ test.register_coroutine_test( }) test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm31_sn:generate_info_changed({preferences = {notificationChild = true}})) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn.lua index 0a288f27d4..80f7a765a7 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn.lua @@ -83,7 +83,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -116,7 +117,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -139,7 +141,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -162,7 +165,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -188,7 +192,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -257,7 +262,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -288,7 +294,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -324,7 +331,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm32_sn:generate_test_message("button1", capabilities.button.button.pushed_2x({ state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) -- Test illuminance measurement @@ -344,6 +354,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({value = 13})) } + }, + { + min_api_version = 14 } ) @@ -364,6 +377,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -384,6 +400,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -404,7 +423,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.powerMeter.power({value = 200.0, unit = "W"})) ) - end + end, + { + min_api_version = 14 + } ) -- Test energy meter @@ -421,7 +443,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_inovelli_vzm32_sn:generate_test_message("main", capabilities.energyMeter.energy({value = 0.212, unit = "kWh"})) ) - end + end, + { + min_api_version = 14 + } ) -- Test energy meter reset command @@ -468,7 +493,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -521,7 +547,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_inovelli_vzm32_sn.id, clusters.IlluminanceMeasurement.attributes.MeasuredValue:configure_reporting(mock_inovelli_vzm32_sn, 10, 600, 11761) }) mock_inovelli_vzm32_sn:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_child.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_child.lua index 26346c04a9..544ee73aea 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_child.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_child.lua @@ -79,7 +79,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -132,7 +133,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device switch off command @@ -163,7 +167,10 @@ test.register_coroutine_test( utils.serialize_int(0, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device level command @@ -219,7 +226,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device color command @@ -279,7 +289,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) -- Test child device color temperature command @@ -339,7 +352,10 @@ test.register_coroutine_test( utils.serialize_int(notificationValue, 4, false, false) ) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_preferences.lua b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_preferences.lua index 28ae4829a0..a630e54efe 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_preferences.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_inovelli_vzm32_sn_preferences.lua @@ -50,7 +50,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter9 preference change @@ -72,7 +75,10 @@ test.register_coroutine_test( expected_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter52 preference change @@ -93,7 +99,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test parameter258 preference change @@ -114,7 +123,10 @@ test.register_coroutine_test( new_param_value ) }) - end + end, + { + min_api_version = 14 + } ) -- Test notificationChild preference change @@ -130,7 +142,10 @@ test.register_coroutine_test( }) test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzm32_sn:generate_info_changed({preferences = {notificationChild = true}})) - end + end, + { + min_api_version = 14 + } ) -- Test parameter101 preference change @@ -157,7 +172,11 @@ test.register_coroutine_test( mock_inovelli_vzm32_sn.id, expected_command }) - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_jasco_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_jasco_switch.lua index 52ba3ef2a6..14df41e010 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_jasco_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_jasco_switch.lua @@ -5,11 +5,14 @@ local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local OnOffCluster = clusters.OnOff local SimpleMeteringCluster = clusters.SimpleMetering +local ElectricalMeasurementCluster = clusters.ElectricalMeasurement local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" local mock_device = test.mock_device.build_test_zigbee_device({ profile = t_utils.get_profile_definition("switch-power-energy.yml"), + fingerprinted_endpoint_id = 0x01, zigbee_endpoints = { [1] = { id = 1, @@ -21,6 +24,8 @@ local mock_device = test.mock_device.build_test_zigbee_device({ } }) +zigbee_test_utils.prepare_zigbee_env_info() + local function test_init() mock_device:set_field("_configuration_version", 1, {persist = true}) test.mock_device.add_test_device(mock_device) @@ -49,6 +54,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOffCluster.server.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -73,6 +81,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOffCluster.server.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -89,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 33.3, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -110,6 +124,38 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.5555, unit = "kWh" })) } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "doConfigure lifecycle should call refresh and configure on device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + -- device:refresh() reads + test.socket.zigbee:__expect_send({ mock_device.id, OnOffCluster.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMeteringCluster.attributes.InstantaneousDemand:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMeteringCluster.attributes.CurrentSummationDelivered:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ActivePower:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ACPowerDivisor:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ACPowerMultiplier:read(mock_device) }) + -- device:configure() bind requests and configure_reporting + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOffCluster.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMeteringCluster.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMeteringCluster.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMeteringCluster.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurementCluster.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurementCluster.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_laisiao_bath_heather.lua b/drivers/SmartThings/zigbee-switch/src/test/test_laisiao_bath_heather.lua old mode 100755 new mode 100644 index 0457c0eb62..5a2e92e8a3 --- a/drivers/SmartThings/zigbee-switch/src/test/test_laisiao_bath_heather.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_laisiao_bath_heather.lua @@ -48,6 +48,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -65,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch2", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -82,6 +88,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch3", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -99,6 +108,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch4", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -116,6 +128,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch5", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -133,6 +148,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch6", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -150,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch7", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -167,6 +188,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch8", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -186,6 +210,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -203,6 +230,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch2", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -220,6 +250,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch3", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -237,6 +270,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch4", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -254,6 +290,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch5", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -271,6 +310,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch6", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -288,6 +330,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch7", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -305,6 +350,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("switch8", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -315,7 +363,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch2", command = "on", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -325,7 +376,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch3", command = "on", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x03) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -335,7 +389,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch4", command = "on", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x04) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -345,7 +402,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch5", command = "on", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x05) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -355,7 +415,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch6", command = "on", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x06) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -365,7 +428,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch7", command = "on", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x07) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -375,7 +441,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch8", command = "on", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x08) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -385,7 +454,10 @@ test.register_coroutine_test( { capability = "switch", component = "main", command = "off", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x01) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -395,7 +467,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch2", command = "off", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -405,7 +480,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch3", command = "off", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x03) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -415,7 +493,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch4", command = "off", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x04) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -425,7 +506,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch5", command = "off", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x05) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -435,7 +519,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch6", command = "off", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x06) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -445,7 +532,10 @@ test.register_coroutine_test( { capability = "switch", component = "switch7", command = "off", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x07) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -455,7 +545,30 @@ test.register_coroutine_test( { capability = "switch", component = "switch8", command = "off", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x08) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "component main Capability on command emits on then off after delay", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.switch.switch.on()) + ) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.switch.switch.off()) + ) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_ledvance_metering_plug.lua b/drivers/SmartThings/zigbee-switch/src/test/test_ledvance_metering_plug.lua new file mode 100644 index 0000000000..0968fa7c5e --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_ledvance_metering_plug.lua @@ -0,0 +1,107 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local zigbee_constants = require "st.zigbee.constants" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("switch-power-energy.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LEDVANCE", + model = "PLUG COMPACT EU EM T", + server_clusters = { 0x0006, 0x0702 } + } + } + } +) + +local mock_device_eu_em_t = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("switch-power-energy.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LEDVANCE", + model = "PLUG EU EM T", + server_clusters = { 0x0006, 0x0702 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_eu_em_t) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device init should set default multiplier and divisor only when not already set", + function() + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == nil) + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == nil) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == 1) + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == 100) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Device init should preserve device-reported multiplier and divisor", + function() + mock_device:set_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY, 5, {persist = true}) + mock_device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == 5) + assert(mock_device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == 1000) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Device init should set default multiplier and divisor only when not already set - PLUG EU EM T", + function() + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == nil) + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == nil) + test.socket.device_lifecycle:__queue_receive({ mock_device_eu_em_t.id, "init" }) + test.wait_for_events() + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == 1) + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == 100) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Device init should preserve device-reported multiplier and divisor - PLUG EU EM T", + function() + mock_device_eu_em_t:set_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY, 5, {persist = true}) + mock_device_eu_em_t:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 1000, {persist = true}) + test.socket.device_lifecycle:__queue_receive({ mock_device_eu_em_t.id, "init" }) + test.wait_for_events() + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == 5) + assert(mock_device_eu_em_t:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == 1000) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch.lua index 1ff11bd754..9e1ccb8d5e 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch.lua @@ -37,6 +37,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch1", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -54,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch2", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) test.register_message_test( @@ -70,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch3", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -87,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch1", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -103,6 +115,9 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, OnOff.server.commands.On(mock_simple_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_no_master.lua b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_no_master.lua index 6603a0831c..1d5d8b712a 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_no_master.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_no_master.lua @@ -103,6 +103,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -133,6 +136,9 @@ test.register_message_test( { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -163,6 +169,9 @@ test.register_message_test( { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -193,6 +202,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -223,6 +235,9 @@ test.register_message_test( { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -253,6 +268,9 @@ test.register_message_test( { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -277,6 +295,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device) } } + }, + { + min_api_version = 14 } ) @@ -301,6 +322,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -325,6 +349,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) @@ -349,6 +376,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device) } } + }, + { + min_api_version = 14 } ) @@ -373,6 +403,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -397,6 +430,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) @@ -469,8 +505,56 @@ test.register_coroutine_test( 3 ):to_endpoint(3) }) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device) }) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(2) }) + test.socket.zigbee:__expect_send({ mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(3) }) mock_base_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } +) + +local mock_non_mns_device = test.mock_device.build_test_zigbee_device( + { + label = "Generic Switch 1", + profile = profile, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "GenericMfr", + model = "GenericModel-DualSwitch", + server_clusters = { 0x0006 } + }, + [2] = { + id = 2, + manufacturer = "GenericMfr", + model = "GenericModel-DualSwitch", + server_clusters = { 0x0006 } + } + }, + fingerprinted_endpoint_id = 0x01 + } +) + +test.register_coroutine_test( + "device added lifecycle creates child device for secondary OnOff endpoint", + function() + test.socket.device_lifecycle:__queue_receive({ mock_non_mns_device.id, "added" }) + mock_non_mns_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Generic Switch 1 2", + profile = "basic-switch", + parent_device_id = mock_non_mns_device.id, + parent_assigned_child_key = "02" + }) + end, + { + test_init = function() + test.mock_device.add_test_device(mock_non_mns_device) + end, + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_power.lua index 4e35d5067d..5df2466cb2 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_power.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_multi_switch_power.lua @@ -116,7 +116,8 @@ test.register_coroutine_test( { test_init = function() -- no op to avoid auto device add and immediate init event on driver startup - end + end, + min_api_version = 14 } ) @@ -139,7 +140,8 @@ test.register_coroutine_test( { test_init = function() -- no op to avoid auto device add and immediate init event on driver startup - end + end, + min_api_version = 14 } ) @@ -169,7 +171,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -199,7 +202,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -225,6 +229,9 @@ test.register_message_test( { device_uuid = mock_child_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -250,6 +257,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -275,6 +285,9 @@ test.register_message_test( { device_uuid = mock_child_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -300,6 +313,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -327,6 +343,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -354,6 +373,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -378,6 +400,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -402,6 +427,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 14 } ) @@ -426,6 +454,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -450,6 +481,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 14 } ) @@ -473,7 +507,10 @@ test.register_coroutine_test( mock_base_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_base_device):to_endpoint(0x01) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -489,7 +526,10 @@ test.register_coroutine_test( mock_parent_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_child_device):to_endpoint(0x02) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_on_off_zigbee_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_on_off_zigbee_bulb.lua index 7f0d412016..062bd8fc6c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_on_off_zigbee_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_on_off_zigbee_bulb.lua @@ -45,6 +45,9 @@ test.register_message_test( { device_uuid = mock_simple_device.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -70,6 +73,9 @@ test.register_message_test( { device_uuid = mock_simple_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -95,6 +101,9 @@ test.register_message_test( { device_uuid = mock_simple_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -121,6 +130,9 @@ test.register_message_test( math.floor(57 * 0xFE / 100), 0) } } + }, + { + min_api_version = 14 } ) @@ -161,7 +173,10 @@ test.register_coroutine_test( 1) }) mock_simple_device:expect_metadata_update({provisioning_state = "PROVISIONED"}) - end + end, + { + min_api_version = 14 + } ) -- test.register_coroutine_test( diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_osram_iqbr30_light.lua b/drivers/SmartThings/zigbee-switch/src/test/test_osram_iqbr30_light.lua index 082aa0c5c2..4d846e8380 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_osram_iqbr30_light.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_osram_iqbr30_light.lua @@ -63,7 +63,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -71,7 +74,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -100,7 +106,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -130,7 +137,8 @@ test.register_message_test( } }, { - inner_block_ordering = "RELAXED" + inner_block_ordering = "RELAXED", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_osram_light.lua b/drivers/SmartThings/zigbee-switch/src/test/test_osram_light.lua index 8e2e847e2c..a09436c9ce 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_osram_light.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_osram_light.lua @@ -63,7 +63,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -71,7 +74,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -100,7 +106,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_rgb_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_rgb_bulb.lua index 68af9fdedb..bac6c6715c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_rgb_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_rgb_bulb.lua @@ -65,7 +65,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -110,7 +113,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -135,7 +139,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -159,7 +166,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -188,7 +198,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_rgbw_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_rgbw_bulb.lua index 24ec471c9d..604780cd9c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_rgbw_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_rgbw_bulb.lua @@ -81,6 +81,18 @@ test.register_coroutine_test( ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) @@ -88,7 +100,74 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 20 + } +) + + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_message_test( @@ -141,7 +220,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -166,7 +246,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -190,7 +273,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -216,7 +302,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device)}) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -239,7 +328,10 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -252,7 +344,10 @@ test.register_coroutine_test( ColorControl.commands.MoveToColorTemperature(mock_device, 200, 0) } ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_2-wire_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_2-wire_dimmer.lua index 1c815e2d7c..462a607694 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_2-wire_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_2-wire_dimmer.lua @@ -54,6 +54,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -78,6 +81,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -103,6 +109,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -120,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 9.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -138,6 +150,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 15.0, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_knob_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_knob_dimmer.lua index 3ed587916c..cf64a89595 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_knob_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_robb_smarrt_knob_dimmer.lua @@ -54,6 +54,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -78,6 +81,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -103,6 +109,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -121,6 +130,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 15.0, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_sengled_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_sengled_color_temp_bulb.lua index e2ddba42d3..7d633129ec 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_sengled_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_sengled_color_temp_bulb.lua @@ -4,6 +4,7 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff @@ -32,6 +33,70 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } +) + test.register_coroutine_test( "Configure should configure all necessary attributes and refresh device", function() @@ -73,8 +138,14 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_message_test( @@ -108,10 +179,67 @@ test.register_message_test( mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) + } } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 20 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 14, + max_api_version = 19 } ) @@ -139,7 +267,57 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device)}) - end + end, + { + min_api_version = 14 + } +) + +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 +local REPORTED_MIRED_MIN = 160 +local REPORTED_MIRED_MAX = 370 + +test.register_coroutine_test( + "Step Color Temperature command with device-reported mired range test", + function() + -- Report non-default range values to verify subsequent step commands do not use defaults. + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:build_test_attr_report(mock_device, REPORTED_MIRED_MAX)}) + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:build_test_attr_report(mock_device, REPORTED_MIRED_MIN)}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({minimum = 2703, maximum = 6250}))) + test.wait_for_events() + + test.socket.capability:__queue_receive({mock_device.id, { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } }) + mock_device:expect_native_cmd_handler_registration("statelessColorTemperatureStep", "stepColorTemperatureByPercent") + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 42, TRANSITION_TIME, REPORTED_MIRED_MIN, REPORTED_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + } + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Step Level command test", + function() + test.socket.capability:__queue_receive({mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + } + ) + test.wait_for_events() + end, + { + min_api_version = 19 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_sengled_dimmer_bulb_with_motion_sensor.lua b/drivers/SmartThings/zigbee-switch/src/test/test_sengled_dimmer_bulb_with_motion_sensor.lua index e28c4de0b6..6ba6eef2b2 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_sengled_dimmer_bulb_with_motion_sensor.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_sengled_dimmer_bulb_with_motion_sensor.lua @@ -67,7 +67,10 @@ test.register_coroutine_test( IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 30, 300, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -78,7 +81,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -89,7 +95,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -108,7 +117,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -127,7 +139,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -146,7 +161,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -170,6 +188,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) @@ -194,6 +215,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "motionSensor", capability_attr_id = "motion" } } }, + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_sinope_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_sinope_dimmer.lua index 0859138a8c..a16d47a4b0 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_sinope_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_sinope_dimmer.lua @@ -71,7 +71,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -103,7 +107,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( "infochanged to check for necessary preferences settings or updated when ledIntensity and minimalIntensity preference settings are zero with swBuild > 106", @@ -138,7 +146,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -170,7 +182,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -196,7 +212,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(600, data_types.Uint16, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -218,7 +238,11 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -254,7 +278,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -286,7 +314,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(updates.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_message_test( @@ -326,6 +358,9 @@ test.register_message_test( Basic.attributes.ApplicationVersion:read(mock_device) } }, + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua old mode 100755 new mode 100644 index 34e5f78640..28bd04a664 --- a/drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_sinope_switch.lua @@ -55,7 +55,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(device_info_copy.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -78,7 +82,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(device_info_copy.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -101,7 +109,11 @@ test.register_coroutine_test( data_types.validate_or_build_type(device_info_copy.preferences.ledIntensity, data_types.Uint8, "payload"))}) test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } + ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_switch_power.lua b/drivers/SmartThings/zigbee-switch/src/test/test_switch_power.lua index 04c17b15de..2a26c95e3c 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_switch_power.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_switch_power.lua @@ -123,7 +123,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.Divisor:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.Multiplier:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -176,7 +179,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -236,7 +240,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_aurora_relay_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_aurora_relay_device) }) test.socket.zigbee:__expect_send({ mock_aurora_relay_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_aurora_relay_device) }) mock_aurora_relay_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -303,7 +310,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_vimar_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_vimar_device) }) test.socket.zigbee:__expect_send({ mock_vimar_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_vimar_device) }) mock_vimar_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua old mode 100755 new mode 100644 index a00f74bddf..7754d6281e --- a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi.lua @@ -1,82 +1,85 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local test = require "integration_test" -local clusters = require "st.zigbee.zcl.clusters" -local BasicCluster = clusters.Basic -local OnOffCluster = clusters.OnOff -local t_utils = require "integration_test.utils" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local profile = t_utils.get_profile_definition("basic-switch.yml") - -local mock_device = test.mock_device.build_test_zigbee_device({ - label = "Zigbee Switch", - profile = profile, - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "_TZ123fas", - server_clusters = { 0x0006 }, - }, - [2] = { - id = 2, - manufacturer = "_TZ123fas", - server_clusters = { 0x0006 }, - }, - }, - fingerprinted_endpoint_id = 0x01 -}) - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init() - mock_device:set_field("_configuration_version", 1, {persist = true}) - test.mock_device.add_test_device(mock_device) -end - -test.set_test_init_function(test_init) - -test.register_coroutine_test( - "lifecycle configure event should configure device", - function () - test.socket.zigbee:__set_channel_ordering("relaxed") - test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) - test.socket.zigbee:__expect_send({ - mock_device.id, - OnOffCluster.attributes.OnOff:read(mock_device) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - OnOffCluster.attributes.OnOff:read(mock_device):to_endpoint(0x02) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300):to_endpoint(1) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300):to_endpoint(2) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - OnOffCluster.ID, 1):to_endpoint(1) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, - zigbee_test_utils.mock_hub_eui, - OnOffCluster.ID, 2):to_endpoint(2) - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_attribute_read(mock_device, BasicCluster.ID, {0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe}) - }) - - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end -) - -test.run_registered_tests() +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local BasicCluster = clusters.Basic +local OnOffCluster = clusters.OnOff +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local profile = t_utils.get_profile_definition("basic-switch.yml") + +local mock_device = test.mock_device.build_test_zigbee_device({ + label = "Zigbee Switch", + profile = profile, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZ123fas", + server_clusters = { 0x0006 }, + }, + [2] = { + id = 2, + manufacturer = "_TZ123fas", + server_clusters = { 0x0006 }, + }, + }, + fingerprinted_endpoint_id = 0x01 +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + mock_device:set_field("_configuration_version", 1, {persist = true}) + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "lifecycle configure event should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({mock_device.id, "doConfigure"}) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300):to_endpoint(1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOffCluster.attributes.OnOff:configure_reporting(mock_device, 0, 300):to_endpoint(2) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOffCluster.ID, 1):to_endpoint(1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, + zigbee_test_utils.mock_hub_eui, + OnOffCluster.ID, 2):to_endpoint(2) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_attribute_read(mock_device, BasicCluster.ID, {0x0004, 0x0000, 0x0001, 0x0005, 0x0007, 0xfffe}) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi_switch.lua index 1ff11bd754..9e1ccb8d5e 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_tuya_multi_switch.lua @@ -37,6 +37,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch1", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -54,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch2", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) test.register_message_test( @@ -70,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch3", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -87,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("switch1", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -103,6 +115,9 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, OnOff.server.commands.On(mock_simple_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_wallhero_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_wallhero_switch.lua index ce1a9e2882..f28334b661 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_wallhero_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_wallhero_switch.lua @@ -118,6 +118,23 @@ local mock_seventh_child = test.mock_device.build_test_child_device( } ) +-- Single button device matching WALL HERO fingerprint (used to test button capability events) +local mock_button_device = test.mock_device.build_test_zigbee_device( + { + label = "WALL HERO Switch 1", + profile = scene_switch_profile_def, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "WALL HERO", + model = "ACL-401S1I", + server_clusters = { 0x0003, 0x0004, 0x0005, 0x0006 } + } + }, + fingerprinted_endpoint_id = 0x01 + } +) + zigbee_test_utils.prepare_zigbee_env_info() local function test_init() @@ -129,7 +146,9 @@ local function test_init() test.mock_device.add_test_device(mock_fourth_child) test.mock_device.add_test_device(mock_fifth_child) test.mock_device.add_test_device(mock_sixth_child) - test.mock_device.add_test_device(mock_seventh_child)end + test.mock_device.add_test_device(mock_seventh_child) + test.mock_device.add_test_device(mock_button_device) +end test.set_test_init_function(test_init) @@ -161,6 +180,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -191,6 +213,9 @@ test.register_message_test( { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -221,6 +246,9 @@ test.register_message_test( { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -251,6 +279,9 @@ test.register_message_test( { device_uuid = mock_third_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -281,6 +312,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -311,6 +345,9 @@ test.register_message_test( { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -341,6 +378,9 @@ test.register_message_test( { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -371,6 +411,9 @@ test.register_message_test( { device_uuid = mock_third_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -383,7 +426,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_parent_device.id, cluster_base.write_manufacturer_specific_attribute(mock_parent_device, 0x0006, 0x6000, 0x1235, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -395,7 +441,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_parent_device.id, cluster_base.write_manufacturer_specific_attribute(mock_parent_device, 0x0006, 0x6000, 0x1235, data_types.Uint8, 0x00) }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -424,6 +473,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 14 } ) @@ -448,6 +500,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -472,6 +527,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) @@ -496,6 +554,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x04) } } + }, + { + min_api_version = 14 } ) @@ -521,6 +582,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 14 } ) @@ -545,6 +609,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -569,6 +636,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) @@ -593,6 +663,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x04) } } + }, + { + min_api_version = 14 } ) @@ -607,7 +680,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_fourth_child:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -620,7 +696,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_fifth_child:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -633,7 +712,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_sixth_child:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -646,7 +728,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_seventh_child:generate_test_message("main", capabilities.button.button.pushed( { state_change = true } ))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -707,7 +792,24 @@ test.register_coroutine_test( mock_base_device.id, OnOff.attributes.OnOff:read(mock_base_device):to_endpoint(0x01) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "device_added lifecycle event should emit button capability events for button device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_button_device.id, "added" }) + test.socket.capability:__expect_send(mock_button_device:generate_test_message("main", + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_button_device:generate_test_message("main", + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }))) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_white_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_white_color_temp_bulb.lua index f024aa45be..1524a8aa85 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_white_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_white_color_temp_bulb.lua @@ -38,6 +38,9 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) @@ -69,12 +72,136 @@ test.register_coroutine_test( ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) } ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14, + max_api_version = 19 + } +) + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = {mock_device.id, {capability = "refresh", component = "main", command = "refresh", args = {}}} + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) + } + } + }, + { + inner_block_ordering = "relaxed", + min_api_version = 20 + } ) test.register_message_test( @@ -111,7 +238,9 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14, + max_api_version = 19 } ) @@ -139,7 +268,10 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_yanmi_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_yanmi_switch.lua old mode 100755 new mode 100644 index 61ad14e75d..889f4f15d6 --- a/drivers/SmartThings/zigbee-switch/src/test/test_yanmi_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_yanmi_switch.lua @@ -99,6 +99,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -129,6 +132,9 @@ test.register_message_test( { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -159,6 +165,9 @@ test.register_message_test( { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -190,6 +199,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -220,6 +232,9 @@ test.register_message_test( { device_uuid = mock_first_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -250,6 +265,9 @@ test.register_message_test( { device_uuid = mock_second_child.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -280,6 +298,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 14 } ) @@ -304,6 +325,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -328,6 +352,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.On(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) @@ -355,6 +382,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x01) } } + }, + { + min_api_version = 14 } ) @@ -379,6 +409,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -403,6 +436,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, OnOff.server.commands.Off(mock_parent_device):to_endpoint(0x03) } } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_ezex_switch.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_ezex_switch.lua index 8995523952..cfcab8e20e 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_ezex_switch.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_ezex_switch.lua @@ -52,6 +52,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -76,6 +79,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -92,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 9.766, unit = "W" })) } + }, + { + min_api_version = 15 } ) @@ -116,6 +125,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -132,6 +144,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 0.009766, unit = "kWh" })) } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_power_consumption_report.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_power_consumption_report.lua index 4514a90cbb..1b4d46446b 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_power_consumption_report.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_power_consumption_report.lua @@ -6,6 +6,8 @@ local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local SimpleMetering = clusters.SimpleMetering +local ElectricalMeasurement = clusters.ElectricalMeasurement +local OnOff = clusters.OnOff local zigbee_test_utils = require "integration_test.zigbee_test_utils" local t_utils = require "integration_test.utils" @@ -17,7 +19,7 @@ local mock_device = test.mock_device.build_test_zigbee_device( id = 1, manufacturer = "DAWON_DNS", model = "PM-B430-ZB", - server_clusters = {} + server_clusters = { 0x0000, 0x0002, 0x0003, 0x0006, 0x0702, 0x0B04 } } } } @@ -62,6 +64,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({value = 0.042, unit = "kWh"})) } + }, + { + min_api_version = 14 } ) @@ -78,7 +83,78 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 32.0, unit = "W" })) } + }, + { + min_api_version = 14 } ) +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ElectricalMeasurement.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, SimpleMetering.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ElectricalMeasurement.attributes.ACPowerMultiplier:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ElectricalMeasurement.attributes.ACPowerDivisor:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ElectricalMeasurement.attributes.ActivePower:configure_reporting(mock_device, 5, 3600, 5) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + SimpleMetering.attributes.InstantaneousDemand:configure_reporting(mock_device, 5, 3600, 5) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + SimpleMetering.attributes.CurrentSummationDelivered:configure_reporting(mock_device, 5, 3600, 1) + } + ) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ActivePower:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerDivisor:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ElectricalMeasurement.attributes.ACPowerMultiplier:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.InstantaneousDemand:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, SimpleMetering.attributes.CurrentSummationDelivered:read(mock_device) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 15 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_rexense.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_rexense.lua index e1519a4d1a..ff732a9589 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_rexense.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zigbee_metering_plug_rexense.lua @@ -47,6 +47,9 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, OnOff.server.commands.On(mock_simple_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) @@ -68,6 +71,9 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, OnOff.server.commands.Off(mock_simple_device):to_endpoint(0x02) } } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua index 1a13044f48..20f6d458a2 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_color_temp_bulb.lua @@ -11,6 +11,12 @@ local OnOff = clusters.OnOff local Level = clusters.Level local ColorControl = clusters.ColorControl +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 +local DEFAULT_MIRED_MIN = 154 +local DEFAULT_MIRED_MAX = 370 + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("color-temp-bulb.yml"), fingerprinted_endpoint_id = 0x01, @@ -42,7 +48,27 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -53,6 +79,8 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) test.wait_for_events() test.mock_time.advance_time(50000) @@ -63,7 +91,32 @@ test.register_coroutine_test( test_init = function() test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "polling") - end + end, + min_api_version = 20 + } +) + +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(50000) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "polling") + end, + min_api_version = 14, + max_api_version = 19 } ) @@ -80,7 +133,53 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Switch command on should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end, + { + min_api_version = 14, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Switch command off should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device)}) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -96,7 +195,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -112,7 +215,54 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "SwitchLevel command setLevel should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = {50} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end + test.socket.zigbee:__expect_send({ mock_device.id, Level.commands.MoveToLevelWithOnOff(mock_device, math.floor(50 / 100.0 * 254), 0xFFFF)}) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end, + { + min_api_version = 14, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "ColorTemperature command setColorTemperature should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {200} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") end + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.commands.MoveToColorTemperature(mock_device, 5000, 0x0000)}) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -129,7 +279,80 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) - end + end, + { + min_api_version = 14, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "StatelessColorTemperatureStep stepColorTemperatureByPercent should trigger delayed refresh on ZLL device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessColorTemperatureStep", component = "main", command = "stepColorTemperatureByPercent", args = { 20 } } }) + mock_device:expect_native_cmd_handler_registration("statelessColorTemperatureStep", "stepColorTemperatureByPercent") + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.server.commands.StepColorTemperature(mock_device, ColorControl.types.CcStepMode.DOWN, 43, TRANSITION_TIME, DEFAULT_MIRED_MIN, DEFAULT_MIRED_MAX, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Rapid StatelessSwitchLevelStep stepLevel commands should cancel and recreate delayed refresh timer", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + test.wait_for_events() + test.mock_time.advance_time(1) + + -- Second step command: cancels timer #1, creates timer #2 + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + + test.wait_for_events() + test.mock_time.advance_time(1) + -- now, nothing should happen since the first timer was cancelled and the second timer has not yet reached its 2s delay + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer.lua index 71305fba20..7738dc659b 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer.lua @@ -65,7 +65,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -73,7 +76,10 @@ test.register_coroutine_test( function() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.capability:__expect_send(mock_device:generate_test_message("main",capabilities.switchLevel.level(100))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -83,7 +89,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -147,7 +156,8 @@ test.register_coroutine_test( test_init = function() test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(5*60, "interval", "polling") - end + end, + min_api_version = 14 } ) @@ -159,7 +169,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua index 0ac17313d3..195e0eb469 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_dimmer_bulb.lua @@ -10,6 +10,10 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local OnOff = clusters.OnOff local Level = clusters.Level +local TRANSITION_TIME = 3 +local OPTIONS_MASK = 0x01 +local IGNORE_COMMAND_IF_OFF = 0x00 + local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("on-off-level.yml"), fingerprinted_endpoint_id = 0x01, @@ -61,7 +65,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -71,7 +78,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -81,7 +91,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +114,8 @@ test.register_coroutine_test( test_init = function() test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "polling") - end + end, + min_api_version = 14 } ) @@ -120,7 +134,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -138,7 +155,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -156,7 +176,33 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "StatelessSwitchLevelStep stepLevel should trigger delayed refresh on ZLL device", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { 25 } } }) + mock_device:expect_native_cmd_handler_registration("statelessSwitchLevelStep", "stepLevel") + test.socket.zigbee:__expect_send({ + mock_device.id, + Level.server.commands.Step(mock_device, Level.types.MoveStepMode.UP, 64, TRANSITION_TIME, OPTIONS_MASK, IGNORE_COMMAND_IF_OFF) + }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + end, + { + min_api_version = 19 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgb_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgb_bulb.lua index 3435b22288..2eeffc86aa 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgb_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgb_bulb.lua @@ -83,7 +83,10 @@ test.register_coroutine_test( } ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -95,7 +98,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -107,7 +113,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -129,7 +138,8 @@ test.register_coroutine_test( test_init = function() test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "polling") - end + end, + min_api_version = 14 } ) @@ -150,7 +160,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -170,7 +183,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -190,97 +206,168 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) -local test_data = { - { hue = 75, saturation = 65, x = 0x3E51, y = 0x255D }, - { hue = 75, saturation = nil, x = 0x500F, y = 0x543B } -} +test.register_coroutine_test( + "Set Hue command test (saturation 65)", + function() -for _, data in ipairs(test_data) do - test.register_coroutine_test( - "Set Hue command test", - function() - if data.saturation ~= nil then - test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.CurrentSaturation:build_test_attr_report(mock_device, math.ceil(data.saturation / 100 * 0xFE))}) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(data.saturation))) - end - - test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.socket.capability:__queue_receive({mock_device.id, - { - capability = "colorControl", - component = "main", - command = "setHue", - args = { data.hue } } - } - ) + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.CurrentSaturation:build_test_attr_report(mock_device, math.ceil(65 / 100 * 0xFE))}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) + mock_device:expect_native_attr_handler_registration("colorControl", "saturation") - test.wait_for_events() - test.mock_time.advance_time(0.2) + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { + capability = "colorControl", + component = "main", + command = "setHue", + args = { 75 } } + } + ) - test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - ColorControl.commands.MoveToColor(mock_device, data.x, data.y, 0x0000) - } - ) + test.wait_for_events() + test.mock_time.advance_time(0.2) - test.wait_for_events() - test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x3E51, 0x255D, 0x0000) + } + ) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end - ) -end + test.wait_for_events() + test.mock_time.advance_time(2) -test_data = { - { hue = 75, saturation = 65, x = 0x3E51, y = 0x255D }, - { hue = nil, saturation = 65, x = 0x86EF, y = 0x5465 } -} -for _, data in ipairs(test_data) do - test.register_coroutine_test( - "Set Saturation command test", - function() - if data.hue ~= nil then - test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.CurrentHue:build_test_attr_report(mock_device, math.ceil(data.hue / 100 * 0xFE))}) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(data.hue))) - end - - test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") - test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") - test.socket.capability:__queue_receive({mock_device.id, - { - capability = "colorControl", - component = "main", - command = "setSaturation", - args = { data.saturation } } - } - ) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end, + { + min_api_version = 19 + } +) - test.wait_for_events() - test.mock_time.advance_time(0.2) +test.register_coroutine_test( + "Set Hue command test (saturation nil)", + function() - test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) - test.socket.zigbee:__expect_send( - { - mock_device.id, - ColorControl.commands.MoveToColor(mock_device, data.x, data.y, 0x0000) - } - ) + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { + capability = "colorControl", + component = "main", + command = "setHue", + args = { 75 } } + } + ) - test.wait_for_events() - test.mock_time.advance_time(2) + test.wait_for_events() + test.mock_time.advance_time(0.2) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end - ) -end + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x500F, 0x543B, 0x0000) + } + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Set Saturation command test (hue 75)", + function() + + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.CurrentHue:build_test_attr_report(mock_device, math.ceil(75 / 100 * 0xFE))}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) + mock_device:expect_native_attr_handler_registration("colorControl", "hue") + + + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { + capability = "colorControl", + component = "main", + command = "setSaturation", + args = { 65 } } + } + ) + + test.wait_for_events() + test.mock_time.advance_time(0.2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x3E51, 0x255D, 0x0000) + } + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "Set Saturation command test (nil hue)", + function() + + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { + capability = "colorControl", + component = "main", + command = "setSaturation", + args = { 65 } } + } + ) + + test.wait_for_events() + test.mock_time.advance_time(0.2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x86EF, 0x5465, 0x0000) + } + ) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end, + { + min_api_version = 14 + } +) test.register_coroutine_test( "Set Hue/Saturation command test", @@ -317,7 +404,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -355,7 +445,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -379,7 +472,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -391,7 +487,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -403,7 +502,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -415,7 +517,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -427,7 +532,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgbw_bulb.lua b/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgbw_bulb.lua index cf53f7e359..0a5d739a48 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgbw_bulb.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_zll_rgbw_bulb.lua @@ -34,6 +34,78 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + } + ) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } +) + + test.register_coroutine_test( "Configure should configure all necessary attributes and refresh device", function() @@ -83,8 +155,14 @@ test.register_coroutine_test( ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16) } ) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -97,7 +175,29 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Refresh necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -110,6 +210,8 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) test.wait_for_events() test.mock_time.advance_time(5*60) @@ -120,10 +222,64 @@ test.register_coroutine_test( test_init = function() test.mock_device.add_test_device(mock_device) test.timer.__create_and_queue_test_time_advance_timer(5*60, "interval", "polling") - end + end, + min_api_version = 20 } ) +test.register_coroutine_test( + "ZLL periodic poll should occur", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.wait_for_events() + + test.mock_time.advance_time(5*60) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.wait_for_events() + end, + { + test_init = function() + test.mock_device.add_test_device(mock_device) + test.timer.__create_and_queue_test_time_advance_timer(5*60, "interval", "polling") + end, + min_api_version = 14, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Capability 'switch' command 'on' should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "on") end + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device) }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + end, + { + min_api_version = 20 + } +) + test.register_coroutine_test( "Capability 'switch' command 'on' should be handled", function() @@ -142,7 +298,12 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -163,7 +324,39 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Capability 'switch' command 'off' should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switch", "off") end + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.Off(mock_device) }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -184,7 +377,67 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + end, + { + min_api_version = 20 + } +) + +test.register_coroutine_test( + "Capability 'switchLevel' command 'setLevel' on should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switchLevel", component = "main", command = "setLevel", args = { 57 } } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("switchLevel", "setLevel") end + + test.socket.zigbee:__expect_send({ mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device, 144, 0xFFFF) }) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + + end, + { + min_api_version = 14, + max_api_version = 19 + } +) + +test.register_coroutine_test( + "ColorTemperature command setColorTemperature should be handled", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {200} } }) + if version.api > 15 then mock_device:expect_native_cmd_handler_registration("colorTemperature", "setColorTemperature") end + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.commands.On(mock_device)}) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.commands.MoveToColorTemperature(mock_device, 5000, 0x0000)}) + + test.wait_for_events() + test.mock_time.advance_time(2) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMaxMireds:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTempPhysicalMinMireds:read(mock_device) }) + + end, + { + min_api_version = 20 + } ) test.register_coroutine_test( @@ -206,7 +459,12 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + + end, + { + min_api_version = 14, + max_api_version = 19 + } ) test.register_coroutine_test( @@ -231,7 +489,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -256,7 +517,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -283,7 +547,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentHue:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentSaturation:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-switch/src/wallhero/init.lua b/drivers/SmartThings/zigbee-switch/src/wallhero/init.lua index 1e0e7eb26e..5ad97bfb29 100644 --- a/drivers/SmartThings/zigbee-switch/src/wallhero/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/wallhero/init.lua @@ -102,7 +102,7 @@ local wallheroswitch = { NAME = "Zigbee Wall Hero Switch", lifecycle_handlers = { added = device_added, - init = configurations.power_reconfig_wrapper(device_init), + init = configurations.reconfig_wrapper(device_init), infoChanged = device_info_changed }, zigbee_handlers = { diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/init.lua index dc37bf1181..869678ef24 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimmer-power-energy/init.lua @@ -41,7 +41,7 @@ local zigbee_dimmer_power_energy_handler = { } }, lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(device_init), + init = configurations.reconfig_wrapper(device_init), doConfigure = do_configure, }, can_handle = require("zigbee-dimmer-power-energy.can_handle"), diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/init.lua index dc8629c57b..880e947163 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dimming-light/init.lua @@ -48,7 +48,7 @@ end local zigbee_dimming_light = { NAME = "Zigbee Dimming Light", lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(device_init), + init = configurations.reconfig_wrapper(device_init), added = device_added, doConfigure = do_configure }, diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/init.lua index b1d10f94a6..f88c6396f5 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-dual-metering-switch/init.lua @@ -52,7 +52,7 @@ local zigbee_dual_metering_switch = { } }, lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(device_init), + init = configurations.reconfig_wrapper(device_init), added = device_added }, can_handle = require("zigbee-dual-metering-switch.can_handle"), diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/init.lua index 17dc0ee2bd..1845878f19 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-metering-plug-power-consumption-report/init.lua @@ -24,6 +24,7 @@ end local do_configure = function(self, device) device:configure() + device:refresh() end local device_init = function(self, device) @@ -40,7 +41,7 @@ local zigbee_metering_plug_power_conumption_report = { } }, lifecycle_handlers = { - init = configurations.power_reconfig_wrapper(device_init), + init = configurations.reconfig_wrapper(device_init), doConfigure = do_configure }, can_handle = require("zigbee-metering-plug-power-consumption-report.can_handle"), diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/fingerprints.lua index d277d36967..f393f61eea 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/fingerprints.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/fingerprints.lua @@ -14,5 +14,7 @@ return { { mfr = "SALUS", model = "SX885ZB" }, { mfr = "AduroSmart Eria", model = "AD-SmartPlug3001" }, { mfr = "AduroSmart Eria", model = "BPU3" }, - { mfr = "AduroSmart Eria", model = "BDP3001" } + { mfr = "AduroSmart Eria", model = "BDP3001" }, + { mfr = "LEDVANCE", model = "PLUG COMPACT EU EM T" }, + { mfr = "LEDVANCE", model = "PLUG EU EM T" } } diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/can_handle.lua new file mode 100644 index 0000000000..4ecb5ab271 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require("zigbee-switch-power.ledvance-metering-plug.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("zigbee-switch-power.ledvance-metering-plug") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/fingerprints.lua new file mode 100644 index 0000000000..50542e69ea --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/fingerprints.lua @@ -0,0 +1,7 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "LEDVANCE", model = "PLUG COMPACT EU EM T" }, + { mfr = "LEDVANCE", model = "PLUG EU EM T" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/init.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/init.lua new file mode 100644 index 0000000000..55e703a6d6 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/ledvance-metering-plug/init.lua @@ -0,0 +1,23 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local zigbee_constants = require "st.zigbee.constants" + +local function device_init(driver, device) + if device:get_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY) == nil then + device:set_field(zigbee_constants.SIMPLE_METERING_MULTIPLIER_KEY, 1, {persist = true}) + end + if device:get_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY) == nil then + device:set_field(zigbee_constants.SIMPLE_METERING_DIVISOR_KEY, 100, {persist = true}) + end +end + +local ledvance_metering_plug = { + NAME = "LEDVANCE Metering Plug", + lifecycle_handlers = { + init = device_init + }, + can_handle = require("zigbee-switch-power.ledvance-metering-plug.can_handle") +} + +return ledvance_metering_plug diff --git a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/sub_drivers.lua index 340e1f27c6..0c1ba5eeef 100644 --- a/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-switch/src/zigbee-switch-power/sub_drivers.lua @@ -5,5 +5,6 @@ local lazy_load = require "lazy_load_subdriver" return { lazy_load("zigbee-switch-power.aurora-relay"), - lazy_load("zigbee-switch-power.vimar") + lazy_load("zigbee-switch-power.vimar"), + lazy_load("zigbee-switch-power.ledvance-metering-plug") } diff --git a/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua b/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua index 3478b39bd5..814e0f70ce 100644 --- a/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/zll-polling/init.lua @@ -3,37 +3,65 @@ local device_lib = require "st.device" local clusters = require "st.zigbee.zcl.clusters" +local configurationMap = require "configurations" -local function set_up_zll_polling(driver, device) - local INFREQUENT_POLL_COUNTER = "_infrequent_poll_counter" - local function poll() - local infrequent_counter = device:get_field(INFREQUENT_POLL_COUNTER) or 1 - if infrequent_counter == 12 then - -- do a full refresh once an hour - device:refresh() - infrequent_counter = 0 - else - -- Read On/Off every poll - for _, ep in pairs(device.zigbee_endpoints) do - if device:supports_server_cluster(clusters.OnOff.ID, ep.id) then - device:send(clusters.OnOff.attributes.OnOff:read(device):to_endpoint(ep.id)) - end +local INFREQUENT_POLL_COUNTER = "_infrequent_poll_counter" +local ZLL_POLL_TIMER = "_zll_poll_timer" + +local function do_zll_poll(device) + if device == nil or type(device.get_field) ~= "function" then + return + end + + local infrequent_counter = device:get_field(INFREQUENT_POLL_COUNTER) or 1 + if infrequent_counter == 12 then + -- do a full refresh once an hour + device:refresh() + infrequent_counter = 0 + else + -- Read On/Off every poll + for _, ep in pairs(device.zigbee_endpoints) do + if device:supports_server_cluster(clusters.OnOff.ID, ep.id) then + device:send(clusters.OnOff.attributes.OnOff:read(device):to_endpoint(ep.id)) end - infrequent_counter = infrequent_counter + 1 end - device:set_field(INFREQUENT_POLL_COUNTER, infrequent_counter) + infrequent_counter = infrequent_counter + 1 end + device:set_field(INFREQUENT_POLL_COUNTER, infrequent_counter) +end +local function set_up_zll_polling(driver, device) -- only set this up for non-child devices - if device.network_type == device_lib.NETWORK_TYPE_ZIGBEE then - device.thread:call_on_schedule(5 * 60, poll, "zll_polling") + if device.network_type ~= device_lib.NETWORK_TYPE_ZIGBEE then + return + end + + -- should never happen, but defensive check + local existing_timer = device:get_field(ZLL_POLL_TIMER) + if existing_timer ~= nil then + device.thread:cancel_timer(existing_timer) + end + + local timer = device.thread:call_on_schedule(5 * 60, function() + do_zll_poll(device) + end, "zll_polling") + + device:set_field(ZLL_POLL_TIMER, timer) +end + +local function remove_zll_polling(driver, device) + local existing_timer = device:get_field(ZLL_POLL_TIMER) + if existing_timer ~= nil then + device.thread:cancel_timer(existing_timer) + device:set_field(ZLL_POLL_TIMER, nil) end end local ZLL_polling = { NAME = "ZLL Polling", lifecycle_handlers = { - init = set_up_zll_polling + init = configurationMap.reconfig_wrapper(set_up_zll_polling), + removed = remove_zll_polling }, can_handle = require("zll-polling.can_handle"), } diff --git a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml index d88b3ff89a..ed6afd59b5 100644 --- a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml +++ b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml @@ -1,4 +1,9 @@ zigbeeManufacturer: + - id: "SONOFF/TRV-ZBT" + deviceLabel: SONOFF TRV-ZBT + manufacturer: SONOFF + model: TRV-ZBT + deviceProfileName: thermostat-sonoff-trv-zbt - id: "Vimar/02973-smart-wheel-thermostat" deviceLabel: Vimar Smart Thermostat manufacturer: Vimar @@ -134,6 +139,11 @@ zigbeeManufacturer: manufacturer: LUMI model: lumi.airrtc.agl001 deviceProfileName: thermostat-aqara + - id: "WALL HERO/ACL-403STC1" + deviceLabel: WALLHERO Thermostat ACL-403STC1 + manufacturer: WALL HERO + model: ACL-403STC1 + deviceProfileName: thermostat-thirty-buttons-wallhero zigbeeGeneric: - id: "genericThermostat" deviceLabel: Zigbee Thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml new file mode 100644 index 0000000000..16ad1353b6 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml @@ -0,0 +1,38 @@ +name: thermostat-sonoff-trv-zbt +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + - id: thermostatMode + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: +- name: "childLock" + title: "Child lock" + description: "Enable or disable the child lock of the thermostat." + required: false + preferenceType: enumeration + definition: + options: + 0: "Unlock" + 1: "Lock" + default: 0 +- name: "enterBluetoothPairing" + title: "Enter Bluetooth pairing" + description: "Enable to put the thermostat into Bluetooth pairing mode. The pairing window is controlled by the thermostat firmware." + required: false + preferenceType: boolean + definition: + default: false diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-thirty-buttons-wallhero.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-thirty-buttons-wallhero.yml new file mode 100644 index 0000000000..5a7bff92a8 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-thirty-buttons-wallhero.yml @@ -0,0 +1,249 @@ +name: thermostat-thirty-buttons-wallhero +components: + - id: main + label: 空调(AC) + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: temperatureSetpoint + version: 1 + - id: thermostatMode + version: 1 + - id: fanMode + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: fan + label: 新风(Fresh Air) + capabilities: + - id: switch + version: 1 + - id: fanMode + version: 1 + categories: + - name: Fan + - id: heat + label: 地暖(Floor Heating) + capabilities: + - id: switch + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + categories: + - name: Thermostat + - id: button1 + label: 回家(Home) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button2 + label: 离家(Leave Home) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button3 + label: 晨起(Morning) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button4 + label: 睡眠(Sleep) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button5 + label: 阅读(Reading) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button6 + label: 观影(Movie) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button7 + label: 全开(All On) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button8 + label: 全关(All Off) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button9 + label: 空调温度调高(AC Temp Up) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button10 + label: 空调温度降低(AC Temp Down) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button11 + label: 开窗帘(Open Curtain) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button12 + label: 关窗帘(Close Curtain) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button13 + label: 空调开(AC On) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button14 + label: 空调关(AC Off) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button15 + label: 无风开(No Wind On) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button16 + label: 无风关(No Wind Off) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button17 + label: 制冷(Cooling) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button18 + label: 制热(Heating) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button19 + label: 新风开(Fresh Air On) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button20 + label: 新风关(Fresh Air Off) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button21 + label: 开电视(Turn On TV) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button22 + label: 关电视(Turn Off TV) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button23 + label: 开音乐(Play Music) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button24 + label: 关音乐(Stop Music) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button25 + label: 开纱帘(Open Screen Curtain) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button26 + label: 关纱帘(Close Screen Curtain) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button27 + label: 洗衣服(Wash Clothes) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button28 + label: 衣服护理(Clothes Care) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button29 + label: 用餐(Dining) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: button30 + label: 开大门(Open Main Door) + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/zigbee-thermostat/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-thermostat/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..30f1243e76 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.airrtc.agl001" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/aqara/init.lua b/drivers/SmartThings/zigbee-thermostat/src/aqara/init.lua index 662d337b82..b17b3d9e1c 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/aqara/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -34,9 +24,6 @@ local PRIVATE_ANTIFREEZE_MODE_TEMPERATURE_SETTING_ID = 0x0279 local PRIVATE_VALVE_RESULT_CALIBRATION_ID = 0x027B local PRIVATE_BATTERY_ENERGY_ID = 0x040A -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.airrtc.agl001" } -} local preference_map = { ["stse.notificationOfValveTest"] = { @@ -82,14 +69,6 @@ local function device_info_changed(driver, device, event, args) end end -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function supported_thermostat_modes_handler(driver, device, value) device:emit_event(capabilities.thermostatMode.supportedThermostatModes({ @@ -277,7 +256,7 @@ local aqara_radiator_thermostat_e1_handler = { [capabilities.refresh.commands.refresh.NAME] = do_refresh, } }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } -return aqara_radiator_thermostat_e1_handler \ No newline at end of file +return aqara_radiator_thermostat_e1_handler diff --git a/drivers/SmartThings/zigbee-thermostat/src/danfoss/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/danfoss/can_handle.lua new file mode 100644 index 0000000000..2f9ba97b72 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/danfoss/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_danfoss_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("danfoss.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("danfoss") + end + end + return false +end + +return is_danfoss_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/danfoss/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/danfoss/fingerprints.lua new file mode 100644 index 0000000000..26386b21a9 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/danfoss/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local DANFOSS_THERMOSTAT_FINGERPRINTS = { + { mfr = "Danfoss", model = "eTRV0100" } +} + +return DANFOSS_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/danfoss/init.lua b/drivers/SmartThings/zigbee-thermostat/src/danfoss/init.lua index c5b181955f..f5b0d5434c 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/danfoss/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/danfoss/init.lua @@ -1,19 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local PowerConfiguration = clusters.PowerConfiguration -local DANFOSS_THERMOSTAT_FINGERPRINTS = { - { mfr = "Danfoss", model = "eTRV0100" } -} -local is_danfoss_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(DANFOSS_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local danfoss_thermostat = { NAME = "Danfoss Thermostat Handler", @@ -27,7 +19,7 @@ local danfoss_thermostat = { lifecycle_handlers = { init = battery_defaults.build_linear_voltage_init(2.4, 3.2) }, - can_handle = is_danfoss_thermostat + can_handle = require("danfoss.can_handle"), } -return danfoss_thermostat \ No newline at end of file +return danfoss_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/fidure/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/fidure/can_handle.lua new file mode 100644 index 0000000000..24ee0ea0e2 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/fidure/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function fidure_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Fidure" and device:get_model() == "A1732R3" then + return true, require("fidure") + end + return false +end + +return fidure_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/fidure/init.lua b/drivers/SmartThings/zigbee-thermostat/src/fidure/init.lua index 8b46c65e73..5d87089aa1 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/fidure/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/fidure/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" @@ -38,9 +28,7 @@ local fidure_thermostat = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Fidure" and device:get_model() == "A1732R3" - end + can_handle = require("fidure.can_handle"), } return fidure_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/init.lua b/drivers/SmartThings/zigbee-thermostat/src/init.lua index 0a5e82350e..cfbbd76a0c 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Zigbee Driver utilities local ZigbeeDriver = require "st.zigbee" @@ -51,7 +41,9 @@ local THERMOSTAT_MODE_MAP = { [ThermostatSystemMode.AUTO] = ThermostatMode.thermostatMode.auto, [ThermostatSystemMode.COOL] = ThermostatMode.thermostatMode.cool, [ThermostatSystemMode.HEAT] = ThermostatMode.thermostatMode.heat, - [ThermostatSystemMode.EMERGENCY_HEATING] = ThermostatMode.thermostatMode.emergency_heat + [ThermostatSystemMode.EMERGENCY_HEATING] = ThermostatMode.thermostatMode.emergency_heat, + [ThermostatSystemMode.DRY] = ThermostatMode.thermostatMode.dryair, + [ThermostatSystemMode.FAN_ONLY] = ThermostatMode.thermostatMode.fanonly } local FAN_MODE_MAP = { @@ -362,21 +354,9 @@ local zigbee_thermostat_driver = { doConfigure = do_configure, added = device_added }, - sub_drivers = { - require("zenwithin"), - require("fidure"), - require("sinope"), - require("stelpro-ki-zigbee-thermostat"), - require("stelpro"), - require("lux-konoz"), - require("leviton"), - require("danfoss"), - require("popp"), - require("vimar"), - require("resideo_korea"), - require("aqara") - }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_thermostat_driver, zigbee_thermostat_driver.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-thermostat/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-thermostat/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-thermostat/src/leviton/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/leviton/can_handle.lua new file mode 100644 index 0000000000..054aaa821b --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/leviton/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function leviton_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "HAI" and device:get_model() == "65A01-1" then + return true, require("leviton") + end + return false +end + +return leviton_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/leviton/init.lua b/drivers/SmartThings/zigbee-thermostat/src/leviton/init.lua index 9b1150ae25..4b35916229 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/leviton/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/leviton/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local Thermostat = clusters.Thermostat @@ -128,9 +118,7 @@ local leviton_thermostat = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "HAI" and device:get_model() == "65A01-1" - end + can_handle = require("leviton.can_handle"), } return leviton_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/can_handle.lua new file mode 100644 index 0000000000..b7f7e5220f --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_lux_konoz = function(opts, driver, device) + local FINGERPRINTS = require("lux-konoz.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("lux-konoz") + end + end + return false +end + +return is_lux_konoz diff --git a/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/fingerprints.lua new file mode 100644 index 0000000000..01bb0bac8c --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local LUX_KONOZ_THERMOSTAT_FINGERPRINTS = { + { mfr = "LUX", model = "KONOZ" } +} + +return LUX_KONOZ_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/init.lua b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/init.lua index 815639985c..0609128ac2 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/lux-konoz/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local Thermostat = clusters.Thermostat @@ -19,18 +9,7 @@ local capabilities = require "st.capabilities" local ThermostatMode = capabilities.thermostatMode -local LUX_KONOZ_THERMOSTAT_FINGERPRINTS = { - { mfr = "LUX", model = "KONOZ" } -} -local is_lux_konoz = function(opts, driver, device) - for _, fingerprint in ipairs(LUX_KONOZ_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end -- LUX KONOz reports extra ["auto", "emergency heat"] which, actually, aren't supported local supported_thermostat_modes_handler = function(driver, device, supported_modes) @@ -46,7 +25,7 @@ local lux_konoz = { } } }, - can_handle = is_lux_konoz + can_handle = require("lux-konoz.can_handle"), } return lux_konoz diff --git a/drivers/SmartThings/zigbee-thermostat/src/popp/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/popp/can_handle.lua new file mode 100644 index 0000000000..907350cd1c --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/popp/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_popp_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("popp.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("popp") + end + end + return false +end + +return is_popp_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/popp/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/popp/fingerprints.lua new file mode 100644 index 0000000000..16a5cc0942 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/popp/fingerprints.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local POPP_THERMOSTAT_FINGERPRINTS = { + { + mfr = "D5X84YU", + model = "eT093WRO" + }, + { + mfr = "D5X84YU", + model = "eT093WRG" + } +} + +return POPP_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/popp/init.lua b/drivers/SmartThings/zigbee-thermostat/src/popp/init.lua index 007fb722b0..f842fe8c2b 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/popp/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/popp/init.lua @@ -1,17 +1,6 @@ --- Copyright 2023 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. --- Zigbee driver utilities +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local data_types = require "st.zigbee.data_types" @@ -34,14 +23,6 @@ local ThermostatMode = capabilities.thermostatMode local TemperatureAlarm = capabilities.temperatureAlarm local Switch = capabilities.switch -local POPP_THERMOSTAT_FINGERPRINTS = { { - mfr = "D5X84YU", - model = "eT093WRO" -}, { - mfr = "D5X84YU", - model = "eT093WRG" -} } - local STORED_HEAT_MODE = "stored_heat_mode" local MFG_CODE = 0x1246 @@ -114,15 +95,6 @@ local PREFERENCE_TABLES = { local SUPPORTED_MODES = { ThermostatMode.thermostatMode.heat.NAME, ThermostatMode.thermostatMode.eco.NAME } -local is_popp_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(POPP_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - -- Helpers -- has member check function @@ -425,7 +397,7 @@ local popp_thermostat = { doConfigure = do_configure, infoChanged = info_changed }, - can_handle = is_popp_thermostat + can_handle = require("popp.can_handle"), } return popp_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/can_handle.lua new file mode 100644 index 0000000000..94eee72e87 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function resideo_korea_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Resideo Korea" and device:get_model() == "DT300ST-M000" then + return true, require("resideo_korea") + end + return false +end + +return resideo_korea_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/init.lua b/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/init.lua index a1e68ef02a..14221dd223 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/resideo_korea/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_lib = require "st.device" local device_management = require "st.zigbee.device_management" @@ -178,9 +168,7 @@ local resideo_thermostat = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Resideo Korea" and device:get_model() == "DT300ST-M000" - end + can_handle = require("resideo_korea.can_handle"), } return resideo_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sinope/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sinope/can_handle.lua new file mode 100644 index 0000000000..fdc43dab3b --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sinope/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sinope_thermostat = function(opts, driver, device) + local SINOPE_TECHNOLOGIES_MFR_STRING = "Sinope Technologies" + if device:get_manufacturer() == SINOPE_TECHNOLOGIES_MFR_STRING then + return true, require("sinope") + else + return false + end +end + +return is_sinope_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sinope/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sinope/init.lua index bef41f2b9c..7926b1e0c7 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sinope/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sinope/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local clusters = require "st.zigbee.zcl.clusters" @@ -26,8 +16,6 @@ local ThermostatOperatingState = capabilities.thermostatOperatingState local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint local TemperatureMeasurement = capabilities.temperatureMeasurement -local SINOPE_TECHNOLOGIES_MFR_STRING = "Sinope Technologies" - local SINOPE_CUSTOM_CLUSTER = 0xFF01 local MFR_TIME_FORMAT_ATTRIBUTE = 0x0114 local MFR_AIR_FLOOR_MODE_ATTRIBUTE = 0x0105 @@ -91,13 +79,6 @@ local PREFERENCE_TABLES = { } } -local is_sinope_thermostat = function(opts, driver, device) - if device:get_manufacturer() == SINOPE_TECHNOLOGIES_MFR_STRING then - return true - else - return false - end -end local do_refresh = function(self, device) local attributes = { @@ -176,7 +157,7 @@ local sinope_thermostat = { doConfigure = do_configure, infoChanged = info_changed }, - can_handle = is_sinope_thermostat + can_handle = require("sinope.can_handle"), } return sinope_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua new file mode 100644 index 0000000000..9b270451e8 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device) + if device:get_manufacturer() == "SONOFF" and device:get_model() == "TRV-ZBT" then + return true, require("sonoff") + end + + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua new file mode 100644 index 0000000000..0097dae163 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -0,0 +1,321 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local generic_body = require "st.zigbee.generic_body" +local messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zb_const = require "st.zigbee.constants" +local utils = require "st.utils" + +local PowerConfiguration = clusters.PowerConfiguration +local Thermostat = clusters.Thermostat + +local Battery = capabilities.battery +local Refresh = capabilities.refresh +local TemperatureMeasurement = capabilities.temperatureMeasurement +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode + +local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 +local WORK_MODE_ATTR = 0x0018 +local WORK_MODE_OFF = 0x01 +local WORK_MODE_MANUAL = 0x03 +local WORK_MODE_SCHEDULE = 0x04 +local WORK_MODE_TEMP_MANUAL = 0x05 + +local DEFAULT_MIN_SETPOINT = 5 +local DEFAULT_MAX_SETPOINT = 30 +local SETPOINT_STEP = 0.5 +local CHILD_LOCK_PREFERENCE = "childLock" +local BLUETOOTH_PAIRING_PREFERENCE = "enterBluetoothPairing" +local MIN_HEAT_SETPOINT_LIMIT_FIELD = "min_heat_setpoint_limit" +local MAX_HEAT_SETPOINT_LIMIT_FIELD = "max_heat_setpoint_limit" +local SUPPORTED_MODES = { + ThermostatMode.thermostatMode.off.NAME, + ThermostatMode.thermostatMode.heat.NAME, + ThermostatMode.thermostatMode.auto.NAME, +} + +local CONFIGURATIONS = { + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.LocalTemperature.ID, + minimum_interval = 10, + maximum_interval = 300, + data_type = Thermostat.attributes.LocalTemperature.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.OccupiedHeatingSetpoint.ID, + minimum_interval = 1, + maximum_interval = 600, + data_type = Thermostat.attributes.OccupiedHeatingSetpoint.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.SystemMode.ID, + minimum_interval = 1, + maximum_interval = 600, + data_type = Thermostat.attributes.SystemMode.base_type, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.MinHeatSetpointLimit.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Thermostat.attributes.MinHeatSetpointLimit.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.MaxHeatSetpointLimit.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Thermostat.attributes.MaxHeatSetpointLimit.base_type, + reportable_change = 50, + }, +} + +local function get_setpoint_range(device) + local reported_min = device:get_field(MIN_HEAT_SETPOINT_LIMIT_FIELD) + local reported_max = device:get_field(MAX_HEAT_SETPOINT_LIMIT_FIELD) + local min = reported_min and (reported_min / 100) or DEFAULT_MIN_SETPOINT + local max = reported_max and (reported_max / 100) or DEFAULT_MAX_SETPOINT + + if min > max then + min = DEFAULT_MIN_SETPOINT + max = DEFAULT_MAX_SETPOINT + end + + return min, max +end + +local function emit_setpoint_range(device) + local min, max = get_setpoint_range(device) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpointRange({ + value = { + minimum = min, + maximum = max, + step = SETPOINT_STEP, + }, + unit = "C", + }, { visibility = { displayed = false } })) +end + +local function emit_supported_modes(device) + device:emit_event(ThermostatMode.supportedThermostatModes(SUPPORTED_MODES, { + visibility = { displayed = false }, + })) +end + +local function emit_mode(device, mode) + device:emit_event(ThermostatMode.thermostatMode[mode]()) +end + +local function command_argument(command, name) + if command.args ~= nil and command.args[name] ~= nil then + return command.args[name] + end + + return command.named_args and command.named_args[name] +end + +local function refresh(_, device) + local attributes = { + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + } + + for _, attribute in ipairs(attributes) do + device:send(attribute:read(device)) + end +end + +local function configure(driver, device) + device:configure() + refresh(driver, device) + device:send(Thermostat.attributes.MinHeatSetpointLimit:read(device)) + device:send(Thermostat.attributes.MaxHeatSetpointLimit:read(device)) +end + +local function added(_, device) + emit_supported_modes(device) + emit_setpoint_range(device) +end + +local function init(driver, device) + for _, configuration in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(configuration) + end + added(driver, device) + refresh(driver, device) +end + +local function info_changed(_, device, _, args) + local old_preferences = args.old_st_store and args.old_st_store.preferences or {} + local child_lock = device.preferences[CHILD_LOCK_PREFERENCE] + if child_lock ~= nil and old_preferences[CHILD_LOCK_PREFERENCE] ~= child_lock then + device:send(cluster_base.write_attribute( + device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(tonumber(child_lock) == 1, data_types.Boolean, "payload") + )) + end + + local bluetooth_pairing = device.preferences[BLUETOOTH_PAIRING_PREFERENCE] + if bluetooth_pairing == true and old_preferences[BLUETOOTH_PAIRING_PREFERENCE] ~= true then + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(0x10), + }) + zcl_header.frame_ctrl:set_cluster_specific() + device:send(messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(SONOFF_CLUSTER) or 1, + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), + }), + })) + end +end + +local function local_temperature_handler(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:emit_event(TemperatureMeasurement.temperature({ value = value.value / 100, unit = "C" })) + end +end + +local function heating_setpoint_handler(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = value.value / 100, unit = "C" })) + end +end + +local function setpoint_limit_handler(field) + return function(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:set_field(field, value.value, { persist = true }) + emit_setpoint_range(device) + end + end +end + +local function system_mode_handler(_, device, value) + if value.value == Thermostat.attributes.SystemMode.OFF then + emit_mode(device, ThermostatMode.thermostatMode.off.NAME) + elseif value.value == Thermostat.attributes.SystemMode.AUTO then + emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) + elseif value.value == Thermostat.attributes.SystemMode.HEAT then + emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) + end +end + +local function battery_handler(_, device, value) + device:emit_event(Battery.battery(utils.clamp_value(utils.round(value.value / 2), 0, 100))) +end + +local function work_mode_handler(_, device, value) + if value.value == WORK_MODE_OFF then + emit_mode(device, ThermostatMode.thermostatMode.off.NAME) + elseif value.value == WORK_MODE_SCHEDULE then + emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) + elseif value.value == WORK_MODE_MANUAL or value.value == WORK_MODE_TEMP_MANUAL then + emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) + end +end + +local function set_heating_setpoint(_, device, command) + local setpoint = command_argument(command, "setpoint") + if setpoint >= 40 then + setpoint = utils.f_to_c(setpoint) + end + + local min, max = get_setpoint_range(device) + setpoint = utils.clamp_value(setpoint, min, max) + -- The TRV-ZBT persists heating setpoints in 0.5 C increments. + setpoint = math.floor((setpoint / SETPOINT_STEP) + 0.000001) * SETPOINT_STEP + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(setpoint * 100))) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = setpoint, unit = "C" })) +end + +local function set_mode(_, device, command) + local mode_to_system_mode = { + [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, + [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT, + [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, + } + local mode = command_argument(command, "mode") + local system_mode = mode_to_system_mode[mode] + if system_mode == nil then + return + end + + device:send(Thermostat.attributes.SystemMode:write(device, system_mode)) + emit_mode(device, mode) +end + +local function mode_setter(mode) + return function(driver, device, command) + set_mode(driver, device, { args = { mode = mode } }) + end +end + +local sonoff = { + NAME = "SONOFF TRV-ZBT", + capability_handlers = { + [Refresh.ID] = { + [Refresh.commands.refresh.NAME] = refresh, + }, + [ThermostatHeatingSetpoint.ID] = { + [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint, + }, + [ThermostatMode.ID] = { + [ThermostatMode.commands.setThermostatMode.NAME] = set_mode, + [ThermostatMode.commands.off.NAME] = mode_setter(ThermostatMode.thermostatMode.off.NAME), + [ThermostatMode.commands.heat.NAME] = mode_setter(ThermostatMode.thermostatMode.heat.NAME), + [ThermostatMode.commands.auto.NAME] = mode_setter(ThermostatMode.thermostatMode.auto.NAME), + }, + }, + zigbee_handlers = { + attr = { + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_handler, + }, + [Thermostat.ID] = { + [Thermostat.attributes.LocalTemperature.ID] = local_temperature_handler, + [Thermostat.attributes.OccupiedHeatingSetpoint.ID] = heating_setpoint_handler, + [Thermostat.attributes.MinHeatSetpointLimit.ID] = setpoint_limit_handler(MIN_HEAT_SETPOINT_LIMIT_FIELD), + [Thermostat.attributes.MaxHeatSetpointLimit.ID] = setpoint_limit_handler(MAX_HEAT_SETPOINT_LIMIT_FIELD), + [Thermostat.attributes.SystemMode.ID] = system_mode_handler, + }, + [SONOFF_CLUSTER] = { + [WORK_MODE_ATTR] = work_mode_handler, + }, + }, + }, + lifecycle_handlers = { + added = added, + init = init, + doConfigure = configure, + infoChanged = info_changed, + }, + can_handle = require "sonoff.can_handle", +} + +return sonoff diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/can_handle.lua new file mode 100644 index 0000000000..3f0bebb126 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_stelpro_ki_zigbee_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("stelpro-ki-zigbee-thermostat.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("stelpro-ki-zigbee-thermostat") + end + end + return false +end + +return is_stelpro_ki_zigbee_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/fingerprints.lua new file mode 100644 index 0000000000..2cf1a884d7 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local STELPRO_KI_ZIGBEE_THERMOSTAT_FINGERPRINTS = { + { mfr = "Stelpro", model = "STZB402+" }, + { mfr = "Stelpro", model = "ST218" }, +} + +return STELPRO_KI_ZIGBEE_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/init.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/init.lua index bcc09b271c..d9f53defe1 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro-ki-zigbee-thermostat/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" @@ -30,10 +20,6 @@ local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint local TemperatureMeasurement = capabilities.temperatureMeasurement local TemperatureAlarm = capabilities.temperatureAlarm -local STELPRO_KI_ZIGBEE_THERMOSTAT_FINGERPRINTS = { - { mfr = "Stelpro", model = "STZB402+" }, - { mfr = "Stelpro", model = "ST218" }, -} -- The Groovy DTH stored the raw Celsius values because it was responsible for converting -- to Farenheit if the user's location necessitated. Right now the driver only operates @@ -60,14 +46,6 @@ local THERMOSTAT_MODE_MAP = { [ThermostatSystemMode.EMERGENCY_HEATING] = ThermostatMode.thermostatMode.eco } -local is_stelpro_ki_zigbee_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(STELPRO_KI_ZIGBEE_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function has_member(haystack, needle) for _, value in ipairs(haystack) do @@ -234,7 +212,7 @@ local function set_heating_setpoint(driver, device, command) end if value >= MIN_SETPOINT and value <= MAX_SETPOINT then - device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, value * 100)) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(value * 100))) device:send(Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) device:send(Thermostat.attributes.PIHeatingDemand:read(device)) end @@ -342,7 +320,7 @@ local stelpro_ki_zigbee_thermostat = { added = device_added, doConfigure = do_configure }, - can_handle = is_stelpro_ki_zigbee_thermostat + can_handle = require("stelpro-ki-zigbee-thermostat.can_handle"), } return stelpro_ki_zigbee_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/can_handle.lua new file mode 100644 index 0000000000..117d8ca51d --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_stelpro_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("stelpro.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("stelpro") + end + end + return false +end + +return is_stelpro_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/fingerprints.lua new file mode 100644 index 0000000000..9271bba198 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local STELPRO_THERMOSTAT_FINGERPRINTS = { + { mfr = "Stelpro", model = "MaestroStat" }, + { mfr = "Stelpro", model = "SORB" }, + { mfr = "Stelpro", model = "SonomaStyle" } +} + +return STELPRO_THERMOSTAT_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/init.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/init.lua index 063f423517..d186c46b67 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/stelpro/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -25,20 +15,7 @@ local RX_HEAT_VALUE = 0x7fff local FREEZE_ALRAM_TEMPERATURE = 0 local HEAT_ALRAM_TEMPERATURE = 50 -local STELPRO_THERMOSTAT_FINGERPRINTS = { - { mfr = "Stelpro", model = "MaestroStat" }, - { mfr = "Stelpro", model = "SORB" }, - { mfr = "Stelpro", model = "SonomaStyle" } -} -local is_stelpro_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(STELPRO_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function get_temperature(temperature) return temperature / 100 @@ -128,8 +105,8 @@ local stelpro_thermostat = { added = device_added, infoChanged = info_changed }, - sub_drivers = { require("stelpro.stelpro_sorb"), require("stelpro.stelpro_maestrostat") }, - can_handle = is_stelpro_thermostat + sub_drivers = require("stelpro.sub_drivers"), + can_handle = require("stelpro.can_handle"), } return stelpro_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/can_handle.lua new file mode 100644 index 0000000000..f9410f80ff --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_stelpro_thermostat = function(opts, driver, device) + local FINGERPRINTS = require "stelpro.stelpro_maestrostat.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("stelpro.stelpro_maestrostat") + end + end + return false +end + +return is_stelpro_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/fingerprints.lua new file mode 100644 index 0000000000..600fe09180 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "Stelpro", model = "MaestroStat" }, +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/init.lua similarity index 67% rename from drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat.lua rename to drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/init.lua index c4d81b944a..fecb34666a 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_maestrostat/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -20,19 +9,6 @@ local RelativeHumidity = clusters.RelativeHumidity local Thermostat = clusters.Thermostat local ThermostatUserInterfaceConfiguration = clusters.ThermostatUserInterfaceConfiguration -local STELPRO_THERMOSTAT_FINGERPRINTS = { - { mfr = "Stelpro", model = "MaestroStat" }, -} - -local is_stelpro_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(STELPRO_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - local do_refresh = function(self, device) local attributes = { Thermostat.attributes.LocalTemperature, @@ -74,7 +50,7 @@ local stelpro_maestro_othermostat = { added = device_added, doConfigure = do_configure }, - can_handle = is_stelpro_thermostat + can_handle = require("stelpro.stelpro_maestrostat.can_handle") } return stelpro_maestro_othermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/can_handle.lua new file mode 100644 index 0000000000..d9e99178e5 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_stelpro_sorb_thermostat = function(opts, driver, device) + local FINGERPRINTS = require("stelpro.stelpro_sorb.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("stelpro.stelpro_sorb") + end + end + return false +end + +return is_stelpro_sorb_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/fingerprints.lua new file mode 100644 index 0000000000..eb8731f6dc --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/fingerprints.lua @@ -0,0 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "Stelpro", model = "SORB" }, + { mfr = "Stelpro", model = "SonomaStyle" } +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/init.lua similarity index 66% rename from drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb.lua rename to drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/init.lua index 504d4351e0..39c370960f 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/stelpro_sorb/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -20,20 +9,6 @@ local RelativeHumidity = clusters.RelativeHumidity local Thermostat = clusters.Thermostat local ThermostatUserInterfaceConfiguration = clusters.ThermostatUserInterfaceConfiguration -local STELPRO_THERMOSTAT_FINGERPRINTS = { - { mfr = "Stelpro", model = "SORB" }, - { mfr = "Stelpro", model = "SonomaStyle" } -} - -local is_stelpro_sorb_thermostat = function(opts, driver, device) - for _, fingerprint in ipairs(STELPRO_THERMOSTAT_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end - local do_refresh = function(self, device) local attributes = { Thermostat.attributes.LocalTemperature, @@ -75,7 +50,7 @@ local stelpro_sorb_thermostat = { added = device_added, doConfigure = do_configure }, - can_handle = is_stelpro_sorb_thermostat + can_handle = require("stelpro.stelpro_sorb.can_handle") } return stelpro_sorb_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/stelpro/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/stelpro/sub_drivers.lua new file mode 100644 index 0000000000..aa48c7eda6 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/stelpro/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("stelpro.stelpro_sorb"), + lazy_load_if_possible("stelpro.stelpro_maestrostat") +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua new file mode 100644 index 0000000000..12bccc65a7 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("sonoff"), + lazy_load_if_possible("zenwithin"), + lazy_load_if_possible("fidure"), + lazy_load_if_possible("sinope"), + lazy_load_if_possible("stelpro-ki-zigbee-thermostat"), + lazy_load_if_possible("stelpro"), + lazy_load_if_possible("lux-konoz"), + lazy_load_if_possible("leviton"), + lazy_load_if_possible("danfoss"), + lazy_load_if_possible("popp"), + lazy_load_if_possible("vimar"), + lazy_load_if_possible("resideo_korea"), + lazy_load_if_possible("aqara"), + lazy_load_if_possible("wallhero"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_aqara_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_aqara_thermostat.lua index 84f305d428..d6ae23b544 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_aqara_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_aqara_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local zigbee_test_utils = require "integration_test.zigbee_test_utils" local cluster_base = require "st.zigbee.cluster_base" local clusters = require "st.zigbee.zcl.clusters" @@ -86,7 +75,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_THERM0STAT_VALVE_DETECTION_SWITCH_ID, MFG_CODE, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -98,7 +90,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ANTIFREEZE_MODE_TEMPERATURE_SETTING_ID, MFG_CODE, data_types.Uint32, 500) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -123,7 +118,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -158,7 +156,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", valveCalibration.calibrationState.calibrationFailure())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -187,7 +188,10 @@ test.register_coroutine_test( capabilities.thermostatMode.thermostatMode.antifreezing())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -252,7 +256,10 @@ test.register_coroutine_test( capabilities.valve.valve.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", invisibleCapabilities.invisibleCapabilities({"thermostatHeatingSetpoint"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -277,7 +284,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("ChildLock", capabilities.lock.lock.locked())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -293,7 +303,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(48)) ) - end + end, + { + min_api_version = 14 + } ) -- test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events", function() @@ -337,7 +350,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.manual()) ) - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( @@ -348,7 +364,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_VALVE_SWITCH_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -359,7 +378,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_VALVE_CALIBRATION_ID, MFG_CODE, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 14 + } ) @@ -371,7 +393,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_CHILD_LOCK_ID, MFG_CODE, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 14 + } ) --]] test.register_coroutine_test( @@ -443,7 +468,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_centralite_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_centralite_thermostat.lua index ce735209c4..fdf30687aa 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_centralite_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_centralite_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -57,6 +46,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -74,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -91,6 +86,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -119,7 +117,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -147,7 +148,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_danfoss_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_danfoss_thermostat.lua index 51782ec8e5..33bda1be3e 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_danfoss_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_danfoss_thermostat.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -39,6 +42,9 @@ test.register_message_test( direction = "send", message = mock_device_danfoss:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -47,7 +53,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device_danfoss.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device_danfoss, 2100) }) test.socket.capability:__expect_send(mock_device_danfoss:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -55,14 +64,20 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device_danfoss.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device_danfoss, 2100) }) test.socket.capability:__expect_send(mock_device_danfoss:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Thermostat cooling setpoint reporting should not create setpoint events, the mode is not supported Danfoss", function () test.socket.zigbee:__queue_receive({ mock_device_danfoss.id, Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device_danfoss, 2100) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -85,7 +100,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device_danfoss:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -101,7 +119,10 @@ test.register_message_test( direction = "send", message = mock_device_danfoss:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_fidure_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_fidure_thermostat.lua index 6ceb8fdc52..25ffd9a224 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_fidure_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_fidure_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -72,7 +61,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -84,6 +76,9 @@ test.register_message_test( message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, 3), } } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_leviton_rc.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_leviton_rc.lua index c5a820bdba..7101150397 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_leviton_rc.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_leviton_rc.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -62,7 +51,10 @@ test.register_coroutine_test( mock_device.id, FanControl.attributes.FanMode:configure_reporting(mock_device, 5, 1800, nil):to_endpoint(ENDPOINT)}) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -76,7 +68,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device):to_endpoint(ENDPOINT) }) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device):to_endpoint(ENDPOINT) }) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -84,7 +79,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2100) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -92,7 +90,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.OFF) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.off())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +102,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.ControlSequenceOfOperation:build_test_attr_report(mock_device, 0x02)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({"auto", "cool", "heat", "emergency heat"},{visibility = {displayed = false }}))) - end + end, + { + min_api_version = 19 + } ) test.register_coroutine_test( @@ -109,7 +113,10 @@ test.register_coroutine_test( function () test.socket.zigbee:__queue_receive({ mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, FanControl.attributes.FanMode.AUTO) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatFanMode.thermostatFanMode.auto())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -121,7 +128,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool())) test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2100) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -133,7 +143,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat())) test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2100) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 21.0, unit = "C"}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -144,7 +157,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.COOL)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool())) test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2100) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -155,7 +171,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.EMERGENCY_HEATING)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.emergency_heat())) test.socket.zigbee:__queue_receive({ mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2100) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -164,7 +183,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatHeatingSetpoint.ID, command = "setHeatingSetpoint", args = {21} } }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 21.0, unit = "C"}))) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2100):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -173,7 +195,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatCoolingSetpoint.ID, command = "setCoolingSetpoint", args = {21} } }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({value = 21.0, unit = "C"}))) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2100):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -181,7 +206,10 @@ test.register_coroutine_test( function () test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatMode.ID, command = "setThermostatMode", args = {"cool"} } }) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 3):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -189,7 +217,10 @@ test.register_coroutine_test( function () test.socket.capability:__queue_receive({ mock_device.id, { component = "main", capability = capabilities.thermostatFanMode.ID, command = "setThermostatFanMode", args = {"auto"} } }) test.socket.zigbee:__expect_send( { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 5):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -220,7 +251,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device):to_endpoint(ENDPOINT) }) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device):to_endpoint(ENDPOINT) }) test.socket.zigbee:__expect_send( { mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device):to_endpoint(ENDPOINT) }) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_popp_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_popp_thermostat.lua index 628dea32eb..b351d725bd 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_popp_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_popp_thermostat.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + require "integration_test" -- Mock out globals local test = require "integration_test" @@ -64,6 +67,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -92,7 +98,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -111,7 +120,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, Thermostat.ID, EXTERNAL_WINDOW_OPEN_DETECTION, MFG_CODE, data_types.Boolean, false) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -130,7 +142,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, Thermostat.ID, EXTERNAL_WINDOW_OPEN_DETECTION, MFG_CODE, data_types.Boolean, true) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -151,7 +166,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 5, 300, 50) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -170,6 +188,9 @@ test.register_message_test( { ThermostatMode.thermostatMode.heat.NAME, ThermostatMode.thermostatMode.eco.NAME }, { visibility = { displayed = false } })) } + }, + { + min_api_version = 14 } ) @@ -231,6 +252,9 @@ test.register_message_test( MFG_CODE) } } + }, + { + min_api_version = 14 } ) @@ -265,7 +289,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }))) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -290,7 +317,10 @@ test.register_coroutine_test( capabilities.battery.battery(batt_perc))) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -324,7 +354,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -358,7 +391,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -372,7 +408,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.eco())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -421,7 +460,10 @@ test.register_coroutine_test( EXTERNAL_WINDOW_OPEN_DETECTION, MFG_CODE )}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -435,7 +477,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, Thermostat.ID, attr_report_data, MFG_CODE) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -449,7 +494,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, Thermostat.ID, attr_report_data, MFG_CODE) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_resideo_dt300st_m000.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_resideo_dt300st_m000.lua index 0d8365a521..5a0f8591ed 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_resideo_dt300st_m000.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_resideo_dt300st_m000.lua @@ -1,16 +1,5 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -111,7 +100,11 @@ test.register_coroutine_test("Configure should configure all necessary attribute mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end) +end, +{ + min_api_version = 14 +} +) -------------------------------------------------------------------------------- -- Parent thermostat device @@ -130,7 +123,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes", fun for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_device)}) end -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -140,7 +137,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -153,7 +154,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -165,7 +170,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -176,7 +185,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages", function() test.socket.capability:__queue_receive({mock_device.id, { @@ -187,7 +200,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2100)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages", function() test.socket.capability:__queue_receive({mock_device.id, { @@ -199,7 +216,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages", function() test.socket.capability:__queue_receive({mock_device.id, { @@ -211,7 +232,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 14 +} +) -------------------------------------------------------------------------------- -- First child thermostat device @@ -230,7 +255,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_first_child)}) end -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with first child device", function() test.socket.zigbee:__queue_receive({mock_first_child.id, @@ -240,7 +269,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with first child device", function() test.socket.zigbee:__queue_receive({mock_first_child.id, @@ -253,7 +286,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_first_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with first child device", function() test.socket.zigbee:__queue_receive({mock_first_child.id, @@ -265,7 +302,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with first child device", function() test.socket.zigbee:__queue_receive({mock_first_child.id, @@ -276,7 +317,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with first child device", function() test.socket.capability:__queue_receive({mock_first_child.id, { @@ -287,7 +332,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_first_child, 2100)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with first child device", function() test.socket.capability:__queue_receive({mock_first_child.id, { @@ -299,7 +348,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_first_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with first child device", function() test.socket.capability:__queue_receive({mock_first_child.id, { @@ -311,7 +364,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_first_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 14 +} +) -------------------------------------------------------------------------------- -- Second child thermostat device @@ -330,7 +387,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_second_child)}) end -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with second child device", function() test.socket.zigbee:__queue_receive({mock_second_child.id, @@ -340,7 +401,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with second child device", function() test.socket.zigbee:__queue_receive({mock_second_child.id, @@ -353,7 +418,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_second_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with second child device", function() test.socket.zigbee:__queue_receive({mock_second_child.id, @@ -365,7 +434,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with second child device", function() test.socket.zigbee:__queue_receive({mock_second_child.id, @@ -376,7 +449,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with second child device", function() test.socket.capability:__queue_receive({mock_second_child.id, { @@ -387,7 +464,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_second_child, 2100)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with second child device", function() test.socket.capability:__queue_receive({mock_second_child.id, { @@ -399,7 +480,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_second_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with second child device", function() test.socket.capability:__queue_receive({mock_second_child.id, { @@ -411,7 +496,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_second_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 14 +} +) -------------------------------------------------------------------------------- -- Third child thermostat device @@ -430,7 +519,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_third_child)}) end -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with third child device", function() test.socket.zigbee:__queue_receive({mock_third_child.id, @@ -440,7 +533,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with third child device", function() test.socket.zigbee:__queue_receive({mock_third_child.id, @@ -453,7 +550,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_third_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with third child device", function() test.socket.zigbee:__queue_receive({mock_third_child.id, @@ -465,7 +566,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with third child device", function() test.socket.zigbee:__queue_receive({mock_third_child.id, @@ -476,7 +581,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with third child device", function() test.socket.capability:__queue_receive({mock_third_child.id, { @@ -487,7 +596,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_third_child, 2100)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with third child device", function() test.socket.capability:__queue_receive({mock_third_child.id, { @@ -499,7 +612,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_third_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with third child device", function() test.socket.capability:__queue_receive({mock_third_child.id, { @@ -511,7 +628,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_third_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 14 +} +) -------------------------------------------------------------------------------- -- Forth child thermostat device @@ -530,7 +651,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_forth_child)}) end -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with forth child device", function() test.socket.zigbee:__queue_receive({mock_forth_child.id, @@ -540,7 +665,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with forth child device", function() test.socket.zigbee:__queue_receive({mock_forth_child.id, @@ -553,7 +682,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_forth_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with forth child device", function() test.socket.zigbee:__queue_receive({mock_forth_child.id, @@ -565,7 +698,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with forth child device", function() test.socket.zigbee:__queue_receive({mock_forth_child.id, @@ -576,7 +713,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with forth child device", function() test.socket.capability:__queue_receive({mock_forth_child.id, { @@ -587,7 +728,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_forth_child, 2100)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with forth child device", function() test.socket.capability:__queue_receive({mock_forth_child.id, { @@ -599,7 +744,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_forth_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with forth child device", function() test.socket.capability:__queue_receive({mock_forth_child.id, { @@ -611,7 +760,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_forth_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 14 +} +) -------------------------------------------------------------------------------- -- Fifth child thermostat device @@ -630,7 +783,11 @@ test.register_coroutine_test("Refresh should read all necessary attributes with for _, attribute in pairs(attributes) do test.socket.zigbee:__expect_send({mock_device.id, attribute:read(mock_fifth_child)}) end -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Temperature reporting should create the appropriate events with fifth child device", function() test.socket.zigbee:__queue_receive({mock_fifth_child.id, @@ -640,7 +797,11 @@ test.register_coroutine_test("Temperature reporting should create the appropriat value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Thermostat mode reporting should create the appropriate events with fifth child device", function() test.socket.zigbee:__queue_receive({mock_fifth_child.id, @@ -653,7 +814,11 @@ test.register_coroutine_test("Thermostat mode reporting should create the approp Thermostat.attributes.SystemMode.HEAT)}) test.socket.capability:__expect_send(mock_fifth_child:generate_test_message("main", capabilities.thermostatMode .thermostatMode.heat())) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("ControlSequenceOfOperation reporting should create the appropriate events with fifth child device", function() test.socket.zigbee:__queue_receive({mock_fifth_child.id, @@ -665,7 +830,11 @@ test.register_coroutine_test("ControlSequenceOfOperation reporting should create displayed = false } }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create the appropriate events with fifth child device", function() test.socket.zigbee:__queue_receive({mock_fifth_child.id, @@ -676,7 +845,11 @@ test.register_coroutine_test("OccupiedHeatingSetpoint reporting shoulb create th value = 21.0, unit = "C" }))) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the heating setpoint should generate the appropriate messages with fifth child device", function() test.socket.capability:__queue_receive({mock_fifth_child.id, { @@ -687,7 +860,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a }}) test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_fifth_child, 2100)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to away should generate the appropriate messages with fifth child device", function() test.socket.capability:__queue_receive({mock_fifth_child.id, { @@ -699,7 +876,11 @@ test.register_coroutine_test("Setting the thermostat mode to away should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_fifth_child, Thermostat.attributes.SystemMode.OFF)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("Setting the thermostat mode to heat should generate the appropriate messages with fifth child device", function() test.socket.capability:__queue_receive({mock_fifth_child.id, { @@ -711,7 +892,11 @@ test.register_coroutine_test("Setting the thermostat mode to heat should generat test.socket.zigbee:__expect_send({mock_device.id, Thermostat.attributes.SystemMode:write(mock_fifth_child, Thermostat.attributes.SystemMode.HEAT)}) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test("ThermostatRunningState reporting shoulb create the appropriate events", function() test.socket.zigbee:__queue_receive({mock_device.id, @@ -726,6 +911,10 @@ test.register_coroutine_test("ThermostatRunningState reporting shoulb create the Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0004)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState({value="fan only"}))) -end) +end, +{ + min_api_version = 14 +} +) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1300_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1300_thermostat.lua index 2411d7638c..8fc50e9706 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1300_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1300_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -72,6 +64,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("idle")) } + }, + { + min_api_version = 14 } ) @@ -105,7 +100,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 305) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -198,7 +196,10 @@ test.register_coroutine_test( data_types.validate_or_build_type(3000, data_types.Int16, "payload") ) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1400_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1400_thermostat.lua index 88810a3c80..5d7a679e92 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1400_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_th1400_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -72,6 +64,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("idle")) } + }, + { + min_api_version = 14 } ) @@ -105,7 +100,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 305) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -208,7 +206,10 @@ test.register_coroutine_test( data_types.validate_or_build_type(0x0708, data_types.Uint16, "payload") ) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_thermostat.lua index 9a5ba8aed5..a85e50971f 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sinope_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -72,6 +64,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("idle")) } + }, + { + min_api_version = 14 } ) @@ -105,7 +100,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 305) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -182,7 +180,40 @@ test.register_coroutine_test( ) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Refresh should send read requests for all necessary attributes", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.LocalTemperature:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.PIHeatingDemand:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:read(mock_device) + }) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua new file mode 100644 index 0000000000..150ad7bfe2 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -0,0 +1,429 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local device_management = require "st.zigbee.device_management" +local generic_body = require "st.zigbee.generic_body" +local messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local zb_const = require "st.zigbee.constants" + +local PowerConfiguration = clusters.PowerConfiguration +local Thermostat = clusters.Thermostat +local TemperatureMeasurement = clusters.TemperatureMeasurement + +local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 +local WORK_MODE_ATTR = 0x0018 +local BUTTON_COMMAND = 0x10 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("thermostat-sonoff-trv-zbt.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "TRV-ZBT", + server_clusters = { 0x0001, 0x0201, 0x0402, SONOFF_CLUSTER }, + }, + }, +}) + +zigbee_test_utils.prepare_zigbee_env_info() +test.set_test_init_function(function() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes( + { "off", "heat", "auto" }, { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 5, maximum = 30, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end +end) + +local function bluetooth_pairing_command(device) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(BUTTON_COMMAND), + }) + zcl_header.frame_ctrl:set_cluster_specific() + + return messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + 1, + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), + }), + }) +end + +test.register_message_test( + "SONOFF TRV-ZBT reports local temperature", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2150) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = "C" })), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "SONOFF TRV-ZBT reports setpoint and battery percentage", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2250) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22.5, unit = "C" })), + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 146) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(73)), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "SONOFF TRV-ZBT maps standard and proprietary modes", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.HEAT) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()), + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_CLUSTER, { + { WORK_MODE_ATTR, data_types.Uint8.ID, 0x04 }, + }), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()), + }, + }, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes setpoints in Celsius and Fahrenheit", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 21.5 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2150), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = "C" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 68 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2000), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 20.0, unit = "C" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 64 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1750), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 17.5, unit = "C" })) + ) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT updates setpoint range from reported limits", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, 1200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 12.0, maximum = 30, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, 2200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 12.0, maximum = 22.0, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 25 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22.0, unit = "C" })) + ) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT starts Bluetooth pairing when the preference is enabled", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { enterBluetoothPairing = true }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, bluetooth_pairing_command(mock_device) }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { enterBluetoothPairing = false }, + })) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes the child lock preference", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { childLock = 1 }, + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_attribute( + mock_device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(true, data_types.Boolean, "payload") + ), + }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { childLock = 0 }, + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_attribute( + mock_device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(false, data_types.Boolean, "payload") + ), + }) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes thermostat mode and refreshes required attributes", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {}, + named_args = { mode = "auto" }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.AUTO), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT configures reporting and cluster bindings", + function() + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, Thermostat.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, PowerConfiguration.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, TemperatureMeasurement.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.LocalTemperature:configure_reporting(mock_device, 10, 300, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 1, 600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:configure_reporting(mock_device, 1, 600, 1), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:configure_reporting(mock_device, 30, 21600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:configure_reporting(mock_device, 30, 21600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 600, 100), + }) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:read(mock_device), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:read(mock_device), + }) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_ki_zigbee_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_ki_zigbee_thermostat.lua index d0951b2958..da890c2b11 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_ki_zigbee_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_ki_zigbee_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -68,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 14 } ) @@ -84,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 14 } ) @@ -105,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 55.0, unit = "C"})) } + }, + { + min_api_version = 14 } ) @@ -126,6 +124,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = -1.0, unit = "C"})) } + }, + { + min_api_version = 14 } ) @@ -147,6 +148,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 15.0, unit = "C"})) } + }, + { + min_api_version = 14 } ) @@ -163,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 25.0, unit = "C"})) } + }, + { + min_api_version = 14 } ) @@ -179,6 +186,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", ThermostatMode.thermostatMode.off()) } + }, + { + min_api_version = 14 } ) @@ -198,6 +208,9 @@ test.register_message_test( zigbee_test_utils.build_attribute_read(mock_device, Thermostat.ID, {MFR_SETPOINT_MODE_ATTTRIBUTE}, MFG_CODE) } } + }, + { + min_api_version = 14 } ) @@ -227,7 +240,10 @@ test.register_coroutine_test( Thermostat.attributes.PIHeatingDemand:build_test_attr_report(mock_device, 0) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", ThermostatOperatingState.thermostatOperatingState("idle"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -247,7 +263,10 @@ test.register_coroutine_test( {{ MFR_SETPOINT_MODE_ATTTRIBUTE, data_types.Uint16.ID, 0x04}}, MFG_CODE) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", ThermostatMode.thermostatMode.heat())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -277,7 +296,10 @@ test.register_coroutine_test( mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -307,7 +329,10 @@ test.register_coroutine_test( mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -396,7 +421,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -478,6 +506,9 @@ test.register_message_test( cluster_base.read_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE) } }, + }, + { + min_api_version = 14 } ) @@ -498,7 +529,11 @@ test.register_coroutine_test("Setting the heating setpoint should generate the a mock_device.id, Thermostat.attributes.PIHeatingDemand:read(mock_device) }) -end) +end, +{ + min_api_version = 14 +} +) test.register_coroutine_test( "Setting thermostat mode to eco should generate correct zigbee messages", @@ -526,7 +561,62 @@ test.register_coroutine_test( mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, Thermostat.ID, MFR_SETPOINT_MODE_ATTTRIBUTE, MFG_CODE) }) - end + end, + { + min_api_version = 14 + } +) +test.register_coroutine_test( + "LocalTemperature handler should request PIHeatingDemand when setpoint > temperature", + function() + local RAW_SETPOINT_FIELD = "raw_setpoint" + mock_device:set_field(RAW_SETPOINT_FIELD, 3000, { persist = true }) + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2000) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.PIHeatingDemand:read(mock_device) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 20.0, unit = "C" })) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Setting an unsupported thermostat mode should re-emit the current mode", + function() + -- Establish a known current mode state + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, ThermostatSystemMode.OFF) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", ThermostatMode.thermostatMode.off()) + ) + test.wait_for_events() + + -- "cool" is not in SUPPORTED_MODES for stelpro-ki; the driver re-emits the current mode + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = { "cool" } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", ThermostatMode.thermostatMode.off()) + ) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_thermostat.lua index ab7f4c19cc..bc0b3933c0 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_stelpro_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -60,6 +49,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 18.5, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -81,6 +73,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 14 } ) @@ -102,6 +97,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 14 } ) @@ -133,7 +131,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -164,7 +165,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -184,7 +188,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -204,7 +211,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -225,7 +235,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -241,6 +254,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("idle")) } + }, + { + min_api_version = 14 } ) @@ -257,6 +273,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("heating")) } + }, + { + min_api_version = 14 } ) @@ -273,6 +292,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 18.5, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -289,6 +311,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 25 })) } + }, + { + min_api_version = 14 } ) @@ -328,7 +353,10 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -370,7 +398,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -407,7 +438,10 @@ test.register_coroutine_test( test.wait_for_events() test.socket.zigbee:__set_channel_ordering("relaxed") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -449,7 +483,10 @@ test.register_coroutine_test( }) mock_device_maestro:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -461,7 +498,10 @@ test.register_coroutine_test( test.wait_for_events() -- Event not to be handled by driver test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({preferences = { lock = 1 } })) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -471,7 +511,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({preferences = { lock = 0 } })) test.socket.zigbee:__expect_send({mock_device.id, ThermostatUserInterfaceConfiguration.attributes.KeypadLockout:write(mock_device, 0x00)}) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -506,7 +549,10 @@ test.register_coroutine_test( mock_device.id, RelativeHumidity.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_vimar_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_vimar_thermostat.lua index d41d4f23aa..9ba03896e8 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_vimar_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_vimar_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -96,6 +85,9 @@ test.register_message_test( capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" }) ) } + }, + { + min_api_version = 14 } ) @@ -124,6 +116,9 @@ test.register_message_test( capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 30.0, unit = "C" }) ) } + }, + { + min_api_version = 14 } ) @@ -152,6 +147,9 @@ test.register_message_test( capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 18.0, unit = "C" }) ) } + }, + { + min_api_version = 14 } ) @@ -188,7 +186,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } ) - end + end, + { + min_api_version = 14 + } ) -- Test (SmartThings -> Device) @@ -224,7 +225,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } ) - end + end, + { + min_api_version = 14 + } ) -- Test (SmartThings -> Device) @@ -263,7 +267,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } ) - end + end, + { + min_api_version = 14 + } ) -- Test (SmartThings -> Device) @@ -302,7 +309,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } ) - end + end, + { + min_api_version = 14 + } ) @@ -363,7 +373,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device_vimar_cooling, 2720) } ) - end + end, + { + min_api_version = 14 + } ) @@ -400,7 +413,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } ) - end + end, + { + min_api_version = 14 + } ) -- Test (SmartThings -> Device) @@ -436,7 +452,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } ) - end + end, + { + min_api_version = 14 + } ) -- Test (SmartThings -> Device) @@ -475,7 +494,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } ) - end + end, + { + min_api_version = 14 + } ) -- Test (SmartThings -> Device) @@ -514,7 +536,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } ) - end + end, + { + min_api_version = 14 + } ) @@ -575,7 +600,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device_vimar_heating, 1920) } ) - end + end, + { + min_api_version = 14 + } ) @@ -607,6 +635,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -638,6 +669,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -694,6 +728,9 @@ test.register_message_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device_vimar_heating) } } + }, + { + min_api_version = 14 } ) @@ -751,6 +788,9 @@ test.register_message_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device_vimar_cooling) } } + }, + { + min_api_version = 14 } ) @@ -778,7 +818,10 @@ test.register_coroutine_test( } } ) - end + end, + { + min_api_version = 14 + } ) -- Test (Device -> SmartThings) @@ -805,7 +848,10 @@ test.register_coroutine_test( } } ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_zenwithin_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_zenwithin_thermostat.lua index bd16d17a62..ddd240a0e0 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_zenwithin_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_zenwithin_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -58,7 +47,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -75,6 +67,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("cooling")) } + }, + { + min_api_version = 14 } ) test.register_message_test( @@ -91,6 +86,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -108,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -146,7 +147,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -205,7 +209,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({ "off", "heat", "cool" }, { visibility = { displayed = false } })) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -282,7 +289,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -414,7 +424,10 @@ test.register_coroutine_test( ) test.mock_time.advance_time(2) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -446,7 +459,10 @@ test.register_coroutine_test( ) test.mock_time.advance_time(2) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -458,6 +474,417 @@ test.register_message_test( message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, 3), } } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Setting cooling setpoint while in heat mode should re-emit the current cooling setpoint", + function() + -- Put device in heat mode + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x04) -- HEAT + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()) + ) + test.wait_for_events() + + -- Set a known cooling setpoint state + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2500) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) + ) + test.wait_for_events() + + -- Try to set cooling setpoint while in heat mode; driver defers and re-emits current + test.timer.__create_and_queue_test_time_advance_timer(10, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 27 } } + }) + test.wait_for_events() + + test.mock_time.advance_time(10) + -- After the delay, update_device_setpoint re-emits the unchanged cooling setpoint (25.0 C) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) + ) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "MinHeatSetpointLimit report stores device field", + function () + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, 500) + }) + test.wait_for_events() + assert(mock_device:get_field("minHeatSetpoint") == 500, "minHeatSetpoint field not stored correctly") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "MaxHeatSetpointLimit report stores device field", + function () + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, 3500) + }) + test.wait_for_events() + assert(mock_device:get_field("maxHeatSetpoint") == 3500, "maxHeatSetpoint field not stored correctly") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "MinCoolSetpointLimit report stores device field", + function () + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinCoolSetpointLimit:build_test_attr_report(mock_device, 1600) + }) + test.wait_for_events() + assert(mock_device:get_field("minCoolSetpoint") == 1600, "minCoolSetpoint field not stored correctly") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "MaxCoolSetpointLimit report stores device field", + function () + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxCoolSetpointLimit:build_test_attr_report(mock_device, 3200) + }) + test.wait_for_events() + assert(mock_device:get_field("maxCoolSetpoint") == 3200, "maxCoolSetpoint field not stored correctly") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Heating setpoint is clamped to MinHeatSetpointLimit", + function() + -- Receive MinHeatSetpointLimit = 18.0 C (1800 centidegrees) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, 1800) + }) + test.wait_for_events() + + -- Put device in heat mode so the setpoint is applied immediately + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x04) -- HEAT + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()) + ) + test.wait_for_events() + + -- Try to set heating setpoint below the stored min (16 C < 18 C); expect it clamped to 18 C + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 16 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1800) -- clamped to 18.0 C + }) + test.mock_time.advance_time(2) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Heating setpoint is clamped to MaxHeatSetpointLimit", + function() + -- Receive MaxHeatSetpointLimit = 30.0 C (3000 centidegrees) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, 3000) + }) + test.wait_for_events() + + -- Put device in heat mode + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x04) -- HEAT + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()) + ) + test.wait_for_events() + + -- Try to set heating setpoint above the stored max (35 C > 30 C); expect it clamped to 30 C + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 35 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 3000) -- clamped to 30.0 C + }) + test.mock_time.advance_time(2) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Cooling setpoint is clamped to MinCoolSetpointLimit", + function() + -- Receive MinCoolSetpointLimit = 18.0 C (1800 centidegrees) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinCoolSetpointLimit:build_test_attr_report(mock_device, 1800) + }) + test.wait_for_events() + + -- Put device in cool mode so the setpoint is applied immediately + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x03) -- COOL + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) + ) + test.wait_for_events() + + -- Try to set cooling setpoint below the stored min (16 C < 18 C); expect it clamped to 18 C + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 16 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 1800) -- clamped to 18.0 C + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Cooling setpoint is clamped to MaxCoolSetpointLimit", + function() + -- Receive MaxCoolSetpointLimit = 30.0 C (3000 centidegrees) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxCoolSetpointLimit:build_test_attr_report(mock_device, 3000) + }) + test.wait_for_events() + + -- Put device in cool mode + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x03) -- COOL + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) + ) + test.wait_for_events() + + -- Try to set cooling setpoint above the stored max (35 C > 30 C); expect it clamped to 30 C + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 35 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 3000) -- clamped to 30.0 C + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +-- Integration test: full set_cooling_setpoint clamping path +-- +-- Exercises every branch in set_cooling_setpoint that involves get_field / default: +-- +-- Phase 1 – no limit fields present yet → clamp uses DEFAULT_MIN (4.0) / DEFAULT_MAX (37.5) +-- A value already inside the defaults passes through unchanged. +-- A value above DEFAULT_MAX is clamped down to 37.5 C. +-- +-- Phase 2 – device reports MinCoolSetpointLimit (20.0 C) and MaxCoolSetpointLimit (28.0 C) +-- stored via setpoint_limit_handler → get_field now returns real device limits. +-- A value inside those limits passes through unchanged. +-- A value below MinCoolSetpointLimit is clamped up to 20.0 C. +-- A value above MaxCoolSetpointLimit is clamped down to 28.0 C. +-- +-- Phase 3 – device switches to heat mode. +-- setCoolingSetpoint is rejected: driver defers with 10s backstop, +-- then re-emits the last known cooling setpoint without writing to the device. +test.register_coroutine_test( + "set_cooling_setpoint: default clamp, then device-reported limit clamp, then heat-mode rejection", + function() + -- ── Phase 1: no limit fields – defaults apply ───────────────────────────── + + -- Put device in cool mode so setpoints are applied immediately throughout phase 1 & 2 + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x03) -- COOL + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.cool()) + ) + test.wait_for_events() + + -- 1a. Value within defaults (25 C): written as-is + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 25 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2500) + }) + test.wait_for_events() + + -- Establish the device's reported current cooling setpoint state (needed later for re-emit) + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2500) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) + ) + test.wait_for_events() + + -- 1b. Value above DEFAULT_MAX (38 C > 37.5 C): clamped to 37.5 C → written as 3750 + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 38 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 3750) + }) + test.wait_for_events() + + -- ── Phase 2: device reports real limits; get_field overrides defaults ────── + + -- Device sends MinCoolSetpointLimit = 20.0 C and MaxCoolSetpointLimit = 28.0 C + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinCoolSetpointLimit:build_test_attr_report(mock_device, 2000) + }) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxCoolSetpointLimit:build_test_attr_report(mock_device, 2800) + }) + test.wait_for_events() + + -- 2a. Value inside device limits (24 C): written as-is + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 24 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2400) + }) + test.wait_for_events() + + -- 2b. Value below device MinCoolSetpointLimit (18 C < 20 C): clamped to 20.0 C + -- (18 is above DEFAULT_MIN of 4.0, proving the device field is used, not the default) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 18 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2000) + }) + test.wait_for_events() + + -- 2c. Value above device MaxCoolSetpointLimit (30 C > 28 C): clamped to 28.0 C + -- (30 is below DEFAULT_MAX of 37.5, proving the device field is used, not the default) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 30 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2800) + }) + test.wait_for_events() + + -- ── Phase 3: device switches to heat mode; setCoolingSetpoint is deferred ── + + -- Re-establish a known current cooling setpoint for the re-emit assertion + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, 2400) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 24.0, unit = "C" })) + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, 0x04) -- HEAT + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()) + ) + test.wait_for_events() + + -- 3. setCoolingSetpoint while in heat mode: driver stores the field (clamped to 24 C by + -- device limits), then takes the else-branch and arms the 10s backstop timer. + -- After the timer fires, update_device_setpoint detects heat mode, discards the pending + -- cooling value, and re-emits the last reported cooling setpoint (24.0 C) unchanged. + test.timer.__create_and_queue_test_time_advance_timer(10, "oneshot") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", component = "main", command = "setCoolingSetpoint", args = { 24 } } + }) + test.wait_for_events() + + test.mock_time.advance_time(10) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 24.0, unit = "C" })) + ) + test.wait_for_events() + end, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat.lua index 01f1a0ba74..40f8a58e2d 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -- Mock out globals local test = require "integration_test" @@ -47,6 +36,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -64,6 +56,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -88,6 +83,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -105,6 +103,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -121,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) }, + }, + { + min_api_version = 14 } ) @@ -142,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -159,6 +166,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -176,6 +186,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -204,6 +217,9 @@ test.register_message_test( } )) } + }, + { + min_api_version = 14 } ) @@ -232,6 +248,9 @@ test.register_message_test( } )) } + }, + { + min_api_version = 14 } ) @@ -254,7 +273,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -276,7 +298,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -293,6 +318,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("cooling")) } + }, + { + min_api_version = 14 } ) @@ -331,7 +359,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -348,6 +379,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 14 } ) @@ -376,7 +410,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -404,7 +441,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -432,7 +472,10 @@ test.register_coroutine_test( Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -460,7 +503,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -487,7 +533,10 @@ test.register_coroutine_test( Thermostat.attributes.SystemMode:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -515,7 +564,10 @@ test.register_coroutine_test( FanControl.attributes.FanMode:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -543,7 +595,10 @@ test.register_coroutine_test( FanControl.attributes.FanMode:read(mock_device) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -586,7 +641,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -725,6 +783,9 @@ test.register_message_test( PowerConfiguration.attributes.BatteryAlarmState:read(mock_device) } }, + }, + { + min_api_version = 14 } ) @@ -848,6 +909,9 @@ test.register_message_test( PowerConfiguration.attributes.BatteryAlarmState:read(mock_device) } }, + }, + { + min_api_version = 14 } ) @@ -865,6 +929,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("cool")) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat_wallhero_3in1.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat_wallhero_3in1.lua new file mode 100644 index 0000000000..b258d14727 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_zigbee_thermostat_wallhero_3in1.lua @@ -0,0 +1,1240 @@ +-- 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. + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +local Scenes = clusters.Scenes +local OnOff = clusters.OnOff +local FanControl = clusters.FanControl +local Thermostat = clusters.Thermostat +local ThermostatMode = capabilities.thermostatMode +local FanMode = capabilities.fanMode +local button_attr = capabilities.button.button + +local SUPPORTED_FAN_MODES = { + { "auto", "high", "medium", "low"}, +} + +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("thermostat-thirty-buttons-wallhero.yml"), + zigbee_endpoints = { + [0x01] = { + id = 0x01, + manufacturer = "WALL HERO", + model = "ACL-403STC1", + server_clusters = { 0x0005,0x0006,0x0201,0x0202, 0x0203 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + ThermostatMode.supportedThermostatModes({"cool", "dryair", "fanonly", "heat"}, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + FanMode.supportedFanModes( SUPPORTED_FAN_MODES[1] , { visibility = { displayed = false }}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "fan", + FanMode.supportedFanModes( SUPPORTED_FAN_MODES[1] , { visibility = { displayed = false }}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.temperatureSetpoint.temperatureSetpointRange({ value = { minimum = 16.00, maximum = 32.00 }, unit = "C" }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "heat", + capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 16.00, maximum = 32.00 }, unit = "C" }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.switch.switch.off() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "heat", + capabilities.switch.switch.off() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "fan", + capabilities.switch.switch.off() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + ThermostatMode.thermostatMode.cool() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + FanMode.fanMode.auto() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "fan", + FanMode.fanMode.auto() + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.temperatureSetpoint.temperatureSetpoint({value = 26, unit = "C"}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "heat", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 26, unit = "C"}) + ) + ) + + for _, component in pairs(mock_device.profile.components) do + if component.id ~= "main" and component.id ~= "heat" and component.id ~= "fan" then + test.socket.capability:__expect_send( + mock_device:generate_test_message( + component.id, + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } }) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + component.id, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x01) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.LocalTemperature:read(mock_device):to_endpoint(0x01) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.OccupiedCoolingSetpoint:read(mock_device):to_endpoint(0x01) + } + ) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.SystemMode:read(mock_device):to_endpoint(0x01) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + FanControl.attributes.FanMode:read(mock_device):to_endpoint(0x01) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + FanControl.attributes.FanMode:read(mock_device):to_endpoint(0x02) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x03) + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device):to_endpoint(0x03) + } + ) + test.socket:set_time_advance_per_select(0.1) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + + +test.register_message_test( + "Reported on status should be handled: on ep 1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Temperature reports using the temperatureMeasurement should be handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, + 2500):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Cooling setpoint reports temperatureSetpoint are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:build_test_attr_report(mock_device, + 2500):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureSetpoint.temperatureSetpoint({ value = 25.0, unit = "C" })) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Thermostat running mode reports 3 cool are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, + 3):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("cool")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Thermostat running mode reports 4 heat are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, + 4):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("heat")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Thermostat running mode reports 8 dryair are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, + 8):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("dryair")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Thermostat running mode reports 1 auto are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningMode:build_test_attr_report(mock_device, + 1):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("auto")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "FanControl fan mode reports 1 low are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 1):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("low")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "FanControl fan mode reports 2 medium are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 2):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("medium")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "FanControl fan mode reports 3 high are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 3):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("high")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "FanControl fan mode reports 5 auto are handled : ep1", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 5):from_endpoint(0x01) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.fanMode.fanMode("auto")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("heat", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("heat", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Heating setpoint reports heatingSetpoint are handled : ep3", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, + 2500):from_endpoint(0x03) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("heat", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" })) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Reported on status should be handled: on ep 2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + true):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.switch.switch.on()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Reported off status should be handled: off ep 2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, + false):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.switch.switch.off()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "FanControl fan mode reports are handled : ep2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 1):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.fanMode.fanMode("low")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "FanControl fan mode reports are handled : ep2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 2):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.fanMode.fanMode("medium")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "FanControl fan mode reports are handled : ep2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 3):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.fanMode.fanMode("high")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "FanControl fan mode reports 5 auto are handled : ep2", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, FanControl.attributes.FanMode:build_test_attr_report(mock_device, + 5):from_endpoint(0x02) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("fan", capabilities.fanMode.fanMode("auto")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability on command switch on should be handled : ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability off command switch off should be handled : ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "main", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability on command switch on should be handled : ep3", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "heat", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x03) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability off command switch off should be handled : ep3", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "heat", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x03) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability on command switch on should be handled : ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "fan", command = "on", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability off command switch off should be handled : ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "switch", component = "fan", command = "off", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability temperatureSetpoint command setpoint 27 ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "temperatureSetpoint", component = "main", command = "setTemperatureSetpoint", args = { 27 } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.OccupiedCoolingSetpoint:write(mock_device, 2700):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability thermostat command cool ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = {"cool" } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 3):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability thermostat command dryair ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = {"dryair" } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 8):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability thermostat command fanonly ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = {"fanonly" } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 7):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability thermostat command heat ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatMode", component = "main", command = "setThermostatMode", args = { "heat"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, 4):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability fanMode command auto ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "main", command = "setFanMode", args = {"auto"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 5):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability fanMode command low ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "main", command = "setFanMode", args = {"low"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 1):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability fanMode command medium ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "main", command = "setFanMode", args = {"medium"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 2):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability fanMode command high ep1", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "main", command = "setFanMode" , args = {"high"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 3):to_endpoint(0x01) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability thermostatHeatingSetpoint command setHeatingSetpoint 27 ep3", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "thermostatHeatingSetpoint", component = "heat", command = "setHeatingSetpoint", args = { 27 } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2700):to_endpoint(0x03) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability fanMode command auto ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "fan", command = "setFanMode", args = {"auto"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 5):to_endpoint(0x02) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability fanMode command low ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "fan", command = "setFanMode", args = {"low"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 1):to_endpoint(0x02) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability fanMode command medium ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "fan", command = "setFanMode", args = {"medium"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 2):to_endpoint(0x02) } + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Capability fanMode command high ep2", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "fanMode", component = "fan", command = "setFanMode" , args = {"high"} } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, FanControl.attributes.FanMode:write(mock_device, 3):to_endpoint(0x02) } + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "RecallScene command should be handled", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 4) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button1", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 5) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button2", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 6) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button3", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 7) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button4", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 8) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button5", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 9) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button6", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 10) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button7", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 11) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button8", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 12) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button9", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 13) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button10", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 14) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button11", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 15) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button12", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 16) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button13", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 17) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button14", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 18) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button15", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 19) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button16", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 20) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button17", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 21) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button18", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 22) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button19", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 23) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button20", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 24) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button21", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 25) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button22", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 26) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button23", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 27) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button24", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 28) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button25", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 29) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button26", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 30) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button27", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 31) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button28", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 32) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button29", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 33) }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("button30", button_attr.pushed({ state_change = true })) + ) + + test.wait_for_events() + test.socket.zigbee:__queue_receive({ mock_device.id, zigbee_test_utils.build_custom_command_id(mock_device, Scenes.ID, Scenes.server.commands.RecallScene.ID, 0x0000, "\x05\x00\x00\x00\x05\x00", 34) }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-thermostat/src/vimar/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/vimar/can_handle.lua new file mode 100644 index 0000000000..3aa0478f46 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/vimar/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local vimar_thermostat_can_handle = function(opts, driver, device) + local VIMAR_THERMOSTAT_FINGERPRINT = { + mfr = "Vimar", + model = "WheelThermostat_v1.0" + } + + if device:get_manufacturer() == VIMAR_THERMOSTAT_FINGERPRINT.mfr and + device:get_model() == VIMAR_THERMOSTAT_FINGERPRINT.model then + return true, require("vimar") + end + return false +end + +return vimar_thermostat_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/vimar/init.lua b/drivers/SmartThings/zigbee-thermostat/src/vimar/init.lua index b4070cf569..7ad274449f 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/vimar/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/vimar/init.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local clusters = require "st.zigbee.zcl.clusters" @@ -38,11 +28,6 @@ local VIMAR_THERMOSTAT_MODE_MAP = { [ThermostatSystemMode.HEAT] = ThermostatMode.thermostatMode.heat, } -local VIMAR_THERMOSTAT_FINGERPRINT = { - mfr = "Vimar", - model = "WheelThermostat_v1.0" -} - -- NOTE: This is a global variable to use in order to save the current thermostat profile local VIMAR_CURRENT_PROFILE = "_vimarThermostatCurrentProfile" @@ -50,10 +35,6 @@ local VIMAR_THERMOSTAT_HEATING_PROFILE = "thermostat-fanless-heating-no-fw" local VIMAR_THERMOSTAT_COOLING_PROFILE = "thermostat-fanless-cooling-no-fw" -local vimar_thermostat_can_handle = function(opts, driver, device) - return device:get_manufacturer() == VIMAR_THERMOSTAT_FINGERPRINT.mfr and - device:get_model() == VIMAR_THERMOSTAT_FINGERPRINT.model -end local vimar_thermostat_supported_modes_handler = function(driver, device, supported_modes) device:emit_event( @@ -182,7 +163,7 @@ local vimar_thermostat_subdriver = { } }, doConfigure = vimar_thermostat_do_configure, - can_handle = vimar_thermostat_can_handle + can_handle = require("vimar.can_handle"), } return vimar_thermostat_subdriver diff --git a/drivers/SmartThings/zigbee-thermostat/src/wallhero/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/wallhero/can_handle.lua new file mode 100644 index 0000000000..476deedf2b --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/wallhero/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local FINGERPRINTS = require "wallhero.fingerprints" + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("wallhero") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-thermostat/src/wallhero/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/wallhero/fingerprints.lua new file mode 100644 index 0000000000..d1a79015f1 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/wallhero/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "WALL HERO", model = "ACL-403STC1" } +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/wallhero/init.lua b/drivers/SmartThings/zigbee-thermostat/src/wallhero/init.lua new file mode 100644 index 0000000000..807f2e564b --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/wallhero/init.lua @@ -0,0 +1,232 @@ +-- 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. + +local capabilities = require "st.capabilities" +local log = require "log" +local socket = require "cosock.socket" +local zcl_clusters = require "st.zigbee.zcl.clusters" + +local Scenes = zcl_clusters.Scenes +local OnOff = zcl_clusters.OnOff +local FanControl = zcl_clusters.FanControl +local Thermostat = zcl_clusters.Thermostat +local ThermostatMode = capabilities.thermostatMode +local FanMode = capabilities.fanMode + +local function scenes_cluster_handler(driver, device, zb_rx) + local additional_fields = { + state_change = true + } + --Endpoint 1 to 3 are respectively the air conditioner, fresh air, and floor heating. Endpoint 4 to Endpoint 33 are buttons. + local ep = zb_rx.address_header.src_endpoint.value - 3 + local button_name = "button" .. ep + local event = capabilities.button.button.pushed(additional_fields) + local comp = device.profile.components[button_name] + if comp ~= nil then + device:emit_component_event(comp, event) + else + log.warn("Attempted to emit button event for unknown button: " .. button_name) + end +end + +local function on_off_attr_handler(driver, device, value, zb_rx) + local attr = capabilities.switch.switch + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, value.value and attr.on() or attr.off()) +end + +local function on_handler(driver, device, command) + device:send_to_component(command.component, OnOff.server.commands.On(device)) +end + +local function off_handler(driver, device, command) + device:send_to_component(command.component, OnOff.server.commands.Off(device)) +end + +local function caps_temperatureSetpoint_handler(driver, device, command) + local temperature = tonumber(command.args.setpoint)*100 + device:send_to_component(command.component, Thermostat.attributes.OccupiedCoolingSetpoint:write(device,temperature)) +end + +local SUPPORTED_FAN_MODES = { + { "auto", "high", "medium", "low"}, +} + +local FAN_MODE_TO_ZIGBEE = { + ["auto"] = 0x05, + ["low"] = 0x01, + ["medium"] = 0x02, + ["high"] = 0x03 +} + +local ZIGBEE_TO_FAN_MODES = { + [1] = "low" , + [2] = "medium" , + [3] = "high" , + [5] = "auto" +} + +local function fan_mode_attr_handler(driver, device, value, zb_rx) + local ep = zb_rx.address_header.src_endpoint.value + local str = ZIGBEE_TO_FAN_MODES[value.value] + if ep == 1 then + device:emit_component_event(device.profile.components.main,FanMode.fanMode({ value = str })) + elseif ep == 2 then + device:emit_component_event(device.profile.components.fan,FanMode.fanMode({ value = str })) + end +end + +local function setFanMode_handler(driver, device, command) + local value = FAN_MODE_TO_ZIGBEE[command.args.fanMode] + device:send_to_component(command.component, FanControl.attributes.FanMode:write(device, value)) +end + +local function thermostat_attr_occupiedCoolingSetpoint_handler(driver, device, value, zb_rx) + local ep = zb_rx.address_header.src_endpoint.value + if ep == 1 then + local temp = value.value/100 + device:emit_component_event(device.profile.components.main,capabilities.temperatureSetpoint.temperatureSetpoint({value = temp, unit = "C"})) + end +end + +local function do_refresh(driver, device) + device:send_to_component("main", OnOff.attributes.OnOff:read(device)) + device:send_to_component("main", Thermostat.attributes.LocalTemperature:read(device)) + device:send_to_component("main", Thermostat.attributes.OccupiedCoolingSetpoint:read(device)) + + device:send_to_component("main", Thermostat.attributes.SystemMode:read(device)) + device:send_to_component("main", FanControl.attributes.FanMode:read(device)) + device:send_to_component("fan", OnOff.attributes.OnOff:read(device)) + + device:send_to_component("fan", FanControl.attributes.FanMode:read(device)) + device:send_to_component("heat", OnOff.attributes.OnOff:read(device)) + device:send_to_component("heat", Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) +end + +local function added_handler(driver, device) + device:emit_component_event(device.profile.components.main,ThermostatMode.supportedThermostatModes({"cool", "dryair", "fanonly", "heat"}, { visibility = { displayed = false } })) + device:emit_component_event(device.profile.components.main,FanMode.supportedFanModes( SUPPORTED_FAN_MODES[1] , { visibility = { displayed = false }})) + device:emit_component_event(device.profile.components.fan,FanMode.supportedFanModes( SUPPORTED_FAN_MODES[1] , { visibility = { displayed = false }})) + + device:emit_component_event(device.profile.components.main,capabilities.temperatureSetpoint.temperatureSetpointRange({ value = { minimum = 16.00, maximum = 32.00 }, unit = "C" })) + device:emit_component_event(device.profile.components.heat,capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = 16.00, maximum = 32.00 }, unit = "C" })) + + device:emit_component_event(device.profile.components.main,capabilities.switch.switch.off()) + device:emit_component_event(device.profile.components.heat,capabilities.switch.switch.off()) + device:emit_component_event(device.profile.components.fan,capabilities.switch.switch.off()) + + device:emit_component_event(device.profile.components.main,ThermostatMode.thermostatMode.cool()) + device:emit_component_event(device.profile.components.main,FanMode.fanMode.auto()) + device:emit_component_event(device.profile.components.fan,FanMode.fanMode.auto()) + device:emit_component_event(device.profile.components.main,capabilities.temperatureSetpoint.temperatureSetpoint({value = 26, unit = "C"})) + device:emit_component_event(device.profile.components.heat,capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 26, unit = "C"})) + + for _, component in pairs(device.profile.components) do + if component.id ~= "main" and component.id ~= "heat" and component.id ~= "fan" then + device:emit_component_event(component, + capabilities.button.supportedButtonValues({ "pushed" }, { visibility = { displayed = false } })) + device:emit_component_event(component, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } })) + -- Without this time delay, the state of some buttons cannot be updated + socket.sleep(1) + end + end + do_refresh(driver, device) +end + +local function component_to_endpoint(device, component_id) + local ep_num + if component_id == "main" then + ep_num = 1 + elseif component_id == "fan" then + ep_num = 2 + elseif component_id == "heat" then + ep_num = 3 + end + return ep_num or device.fingerprinted_endpoint_id +end + +local function endpoint_to_component(device, ep) + if ep > 3 then + ep = ep - 3 + local button_comp = string.format("button%d", ep) + if device.profile.components[button_comp] ~= nil then + return button_comp + else + return nil + end + else + if ep == 1 then + return "main" + elseif ep == 2 then + return "fan" + else + return "heat" + end + end +end + +local device_init = function(self, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) +end + +local wallhero_thermostat_3in1 = { + NAME = "Wall Hero thermostat 3in1", + supported_capabilities = { + capabilities.switch, + capabilities.temperatureSetpoint + }, + lifecycle_handlers = { + init = device_init, + added = added_handler + }, + health_check = false, + zigbee_handlers = { + cluster = { + [Scenes.ID] = { + [Scenes.server.commands.RecallScene.ID] = scenes_cluster_handler, + } + }, + attr = { + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = on_off_attr_handler + }, + [FanControl.ID] = { + [FanControl.attributes.FanMode.ID] = fan_mode_attr_handler + }, + [Thermostat.ID] = { + [Thermostat.attributes.OccupiedCoolingSetpoint.ID] = thermostat_attr_occupiedCoolingSetpoint_handler + } + } + }, + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = on_handler, + [capabilities.switch.commands.off.NAME] = off_handler + }, + [capabilities.temperatureSetpoint.ID] = { + [capabilities.temperatureSetpoint.commands.setTemperatureSetpoint.NAME] = caps_temperatureSetpoint_handler + }, + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = setFanMode_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + + can_handle = require("wallhero.can_handle") +} + +return wallhero_thermostat_3in1 diff --git a/drivers/SmartThings/zigbee-thermostat/src/zenwithin/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/zenwithin/can_handle.lua new file mode 100644 index 0000000000..818764c88a --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/zenwithin/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function zenwithin_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Zen Within" and device:get_model() == "Zen-01" then + return true, require("zenwithin") + end + return false +end + +return zenwithin_can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/zenwithin/init.lua b/drivers/SmartThings/zigbee-thermostat/src/zenwithin/init.lua index e4b11de8be..f39da8afe5 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/zenwithin/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/zenwithin/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local utils = require "st.utils" @@ -38,9 +28,9 @@ local HEATING_SETPOINT = "heatingSetpoint" local BAT_MIN = 3.4 -- voltage when device UI starts to die, ie, when battery fails local BAT_MAX = 6.0 -- 4 batteries at 1.5V (6.0V) -local DEFAULT_MIN_SETPOINT = 4.0 -local DEFAULT_MAX_SETPOINT = 37.5 - +local DEFAULT_MIN_SETPOINT = 400 -- 4.0C +local DEFAULT_MAX_SETPOINT = 3750 -- 37.5C +local FAHRENHEIT_THRESHOLD = 40 -- 40C -- In zenwithin sub driver, supported thermostat mode follow preference option because sensor always returns 'all mode possible' local SUPPORTED_THERMOSTAT_MODES = { [0x01] = { ModeAttribute.off.NAME, ModeAttribute.heat.NAME }, @@ -103,11 +93,12 @@ local update_device_setpoint = function(device) local current_mode = device:get_latest_state("main", ThermostatMode.ID, ModeAttribute.NAME) if (current_mode == ModeAttribute.heat.NAME or current_mode == ModeAttribute.emergency_heat.NAME) and cooling_setpoint ~= nil then - cooling_setpoint = device:get_latest_state("main", ThermostatCoolingSetpoint.ID, ThermostatCoolingSetpoint.coolingSetpoint.NAME) -- tried to set cooling setpoint while device was in heat mode + cooling_setpoint = device:get_latest_state("main", ThermostatCoolingSetpoint.ID, ThermostatCoolingSetpoint.coolingSetpoint.NAME) device:emit_event(ThermostatCoolingSetpoint.coolingSetpoint({value = cooling_setpoint, unit = "C"})) cooling_setpoint = nil elseif (current_mode == ModeAttribute.cool.NAME) and heating_setpoint ~= nil then + -- tried to set heating setpoint while device was in cool mode heating_setpoint = device:get_latest_state("main", ThermostatHeatingSetpoint.ID, ThermostatHeatingSetpoint.heatingSetpoint.NAME) device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({value = heating_setpoint, unit = "C"})) heating_setpoint = nil @@ -118,22 +109,23 @@ local update_device_setpoint = function(device) end if (heating_setpoint ~= nil) then - device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(heating_setpoint*100))) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(heating_setpoint))) end if (cooling_setpoint ~= nil) then - device:send(Thermostat.attributes.OccupiedCoolingSetpoint:write(device, utils.round(cooling_setpoint*100))) + device:send(Thermostat.attributes.OccupiedCoolingSetpoint:write(device, utils.round(cooling_setpoint))) end end local set_cooling_setpoint = function(driver, device, command) local value = command.args.setpoint - if value >= 40 then -- we got a command in fahrenheit + if value >= FAHRENHEIT_THRESHOLD then -- we got a command in fahrenheit value = utils.f_to_c(value) end - value = utils.clamp_value(value, - device:get_field(MIN_HEAT_LIMIT) or DEFAULT_MIN_SETPOINT, - device:get_field(MAX_HEAT_LIMIT) or DEFAULT_MAX_SETPOINT) + -- Scale up to centidegrees to align with the rest of the values + value = utils.clamp_value(value * 100, + device:get_field(MIN_COOL_LIMIT) or DEFAULT_MIN_SETPOINT, + device:get_field(MAX_COOL_LIMIT) or DEFAULT_MAX_SETPOINT) device:set_field(COOLING_SETPOINT, value) local current_mode = device:get_latest_state("main", ThermostatMode.ID, ModeAttribute.NAME) if current_mode == ModeAttribute.cool.NAME or current_mode == ModeAttribute.auto.NAME then @@ -148,10 +140,11 @@ end local set_heating_setpoint = function(driver, device, command) local value = command.args.setpoint - if value >= 40 then -- we got a command in fahrenheit + if value >= FAHRENHEIT_THRESHOLD then -- we got a command in fahrenheit value = utils.f_to_c(value) end - value = utils.clamp_value(value, + -- Scale up to centidegrees to align with the rest of the values + value = utils.clamp_value(value * 100, device:get_field(MIN_HEAT_LIMIT) or DEFAULT_MIN_SETPOINT, device:get_field(MAX_HEAT_LIMIT) or DEFAULT_MAX_SETPOINT) device:set_field(HEATING_SETPOINT, value) @@ -213,9 +206,7 @@ local zenwithin_thermostat = { infoChanged = info_changed, init = battery_defaults.build_linear_voltage_init(BAT_MIN, BAT_MAX) }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Zen Within" and device:get_model() == "Zen-01" - end + can_handle = require("zenwithin.can_handle"), } return zenwithin_thermostat diff --git a/drivers/SmartThings/zigbee-valve/src/ezex/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/ezex/can_handle.lua new file mode 100644 index 0000000000..f16f04858c --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/ezex/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function ezex_can_handle(opts, driver, device, ...) + local clusters = require "st.zigbee.zcl.clusters" + if device:get_model() == "E253-KR0B0ZX-HA" and not device:supports_server_cluster(clusters.PowerConfiguration.ID) then + return true, require("ezex") + end + return false +end + +return ezex_can_handle diff --git a/drivers/SmartThings/zigbee-valve/src/ezex/init.lua b/drivers/SmartThings/zigbee-valve/src/ezex/init.lua index 47ced66806..c32fe60bac 100644 --- a/drivers/SmartThings/zigbee-valve/src/ezex/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/ezex/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -71,9 +61,7 @@ local ezex_valve = { lifecycle_handlers = { init = device_init }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "E253-KR0B0ZX-HA" and not device:supports_server_cluster(clusters.PowerConfiguration.ID) - end + can_handle = require("ezex.can_handle"), } return ezex_valve diff --git a/drivers/SmartThings/zigbee-valve/src/init.lua b/drivers/SmartThings/zigbee-valve/src/init.lua index 6d355de8ec..717012e2bb 100644 --- a/drivers/SmartThings/zigbee-valve/src/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" @@ -51,11 +41,9 @@ local zigbee_valve_driver_template = { lifecycle_handlers = { added = device_added }, - sub_drivers = { - require("sinope"), - require("ezex") - }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_valve_driver_template, zigbee_valve_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-valve/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-valve/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-valve/src/sinope/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/sinope/can_handle.lua new file mode 100644 index 0000000000..f533d120e0 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sinope/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function sinope_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Sinope Technologies" then + return true, require("sinope") + end + return false +end + +return sinope_can_handle diff --git a/drivers/SmartThings/zigbee-valve/src/sinope/init.lua b/drivers/SmartThings/zigbee-valve/src/sinope/init.lua index 6a0075cd33..0c6994ad00 100644 --- a/drivers/SmartThings/zigbee-valve/src/sinope/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/sinope/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" @@ -22,7 +12,9 @@ local PowerConfiguration = clusters.PowerConfiguration local function device_init(driver, device) battery_defaults.use_battery_voltage_handling(device) -- according to the DTH, this attribute cannot be configured for reporting - device.thread:call_on_schedule(900, function() device:send(PowerConfiguration.attributes.BatteryVoltage:read()) end) + device.thread:call_on_schedule(900, function() + device:send(PowerConfiguration.attributes.BatteryVoltage:read(device)) + end) end local function battery_voltage_handler(driver, device, command) @@ -59,9 +51,7 @@ local sinope_valve = { lifecycle_handlers = { init = device_init }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Sinope Technologies" - end + can_handle = require("sinope.can_handle"), } return sinope_valve diff --git a/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua new file mode 100644 index 0000000000..258579c7fb --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("sinope"), + lazy_load_if_possible("ezex"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_ezex_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_ezex_valve.lua index eb96363d99..013e79a828 100644 --- a/drivers/SmartThings/zigbee-valve/src/test/test_ezex_valve.lua +++ b/drivers/SmartThings/zigbee-valve/src/test/test_ezex_valve.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -55,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 14 } ) @@ -73,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 14 } ) @@ -89,6 +85,28 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(5)) } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Battery percentage report - not low should return 50%", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.attributes.ZoneStatus:build_test_attr_report(mock_device, 0x0000) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) + } + }, + { + min_api_version = 14 } ) @@ -106,6 +124,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown()) } + }, + { + min_api_version = 14 } ) @@ -123,6 +144,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 14 } ) @@ -140,6 +164,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 14 } ) @@ -157,6 +184,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.dc()) } + }, + { + min_api_version = 14 } ) @@ -173,6 +203,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -189,6 +222,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -234,7 +270,10 @@ test.register_coroutine_test( IASZone.attributes.ZoneStatus:configure_reporting(mock_device, 0, 3600, 1) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -274,7 +313,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -312,7 +352,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sinope_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sinope_valve.lua index 344c6b0814..c081d0ab40 100644 --- a/drivers/SmartThings/zigbee-valve/src/test/test_sinope_valve.lua +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sinope_valve.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -57,7 +47,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.PowerSource:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -102,7 +95,10 @@ test.register_coroutine_test( zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Basic.ID) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -122,7 +118,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.PowerSource:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -130,7 +129,10 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 55) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(20)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -138,7 +140,21 @@ test.register_coroutine_test( function() test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 0) }) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(0)) ) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Battery voltage above max should clamp to 100 percent", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:build_test_attr_report(mock_device, 65) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(100)) ) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_zigbee_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_zigbee_valve.lua index baa4252bd2..36eb70de1d 100644 --- a/drivers/SmartThings/zigbee-valve/src/test/test_zigbee_valve.lua +++ b/drivers/SmartThings/zigbee-valve/src/test/test_zigbee_valve.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -55,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 14 } ) @@ -73,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 14 } ) @@ -89,6 +85,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -106,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown()) } + }, + { + min_api_version = 14 } ) @@ -123,6 +125,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 14 } ) @@ -140,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 14 } ) @@ -157,6 +165,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.dc()) } + }, + { + min_api_version = 14 } ) @@ -173,6 +184,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -189,6 +203,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -235,7 +252,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -275,7 +295,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -313,7 +334,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-vent/src/test/test_zigbee_vent.lua b/drivers/SmartThings/zigbee-vent/src/test/test_zigbee_vent.lua index 524a16d580..d4781ddfd3 100644 --- a/drivers/SmartThings/zigbee-vent/src/test/test_zigbee_vent.lua +++ b/drivers/SmartThings/zigbee-vent/src/test/test_zigbee_vent.lua @@ -59,6 +59,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -76,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -93,6 +99,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -108,7 +117,10 @@ test.register_coroutine_test( test.mock_time.advance_time(1) test.socket.zigbee:__expect_send({mock_device.id, Level.attributes.CurrentLevel:read(mock_device)}) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -124,6 +136,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C"})) } + }, + { + min_api_version = 14 } ) @@ -145,6 +160,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -222,7 +240,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -278,7 +297,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) @@ -296,7 +318,10 @@ test.register_coroutine_test( test.wait_for_events() test.socket.capability:__queue_receive({mock_device.id, { capability = "switch", component = "main", command = "on", args = {}}}) test.socket.zigbee:__expect_send({mock_device.id, Level.commands.MoveToLevelWithOnOff(mock_device, math.floor(83 / 100 * 254), 0xFFFF)}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -306,7 +331,10 @@ test.register_coroutine_test( mock_device, 50 )}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.battery.battery(50))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -315,7 +343,10 @@ test.register_coroutine_test( test.socket.zigbee:__queue_receive({mock_device.id, zigbee_test_utils.build_attribute_report(mock_device, clusters.PressureMeasurement.ID, {{ KEEN_PRESSURE_ATTRIBUTE, data_types.Uint16.ID, 10000}}, KEEN_MFG_CODE)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.atmosphericPressureMeasurement.atmosphericPressure({value = 1, unit = "kPa"}))) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml index 5b1c1e4977..9745b5c652 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-water-leak-sensor/fingerprints.yml @@ -108,9 +108,14 @@ 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 model: NAS_WS11 - deviceProfileName: water-battery \ No newline at end of file + deviceProfileName: water-battery + - id: MultIR/MIR-WA100 + deviceLabel: MultiIR Water Leak Sensor MIR-WA100 + manufacturer: MultIR + model: MIR-WA100 + deviceProfileName: water-battery-no-fw-update diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/profiles/water-battery-no-fw-update.yml b/drivers/SmartThings/zigbee-water-leak-sensor/profiles/water-battery-no-fw-update.yml new file mode 100644 index 0000000000..03a891c23b --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/profiles/water-battery-no-fw-update.yml @@ -0,0 +1,12 @@ +name: water-battery-no-fw-update +components: +- id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: LeakSensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..f0ba630b6f --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.flood.agl02" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/init.lua index 7319bae636..595a10f64e 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/aqara/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -10,9 +13,6 @@ local MFG_CODE = 0x115F local PRIVATE_CLUSTER_ID = 0xFCC0 local PRIVATE_ATTRIBUTE_ID = 0x0009 -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.flood.agl02" } -} local CONFIGURATIONS = { { @@ -25,14 +25,6 @@ local CONFIGURATIONS = { } } -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_added(driver, device) device:emit_event(capabilities.waterSensor.water.dry()) @@ -56,7 +48,7 @@ local aqara_contact_handler = { init = device_init, added = device_added }, - can_handle = is_aqara_products + can_handle = require("aqara.can_handle"), } return aqara_contact_handler diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/configurations.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/configurations.lua index a5086d979e..92b7ef226b 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/configurations.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/configurations.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local PowerConfiguration = clusters.PowerConfiguration diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/can_handle.lua new file mode 100644 index 0000000000..e7c5dd9c23 --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function frient_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "frient A/S" and device:get_model() == "FLSZB-110" then + return true, require("frient") + end + return false +end + +return frient_can_handle diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/init.lua index 5e92ef80d6..4357eb91b2 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/frient/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local TemperatureMeasurement = clusters.TemperatureMeasurement @@ -43,9 +33,7 @@ local frient_water_leak_sensor = { init = device_init, doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "frient A/S" and device:get_model() == "FLSZB-110" - end + can_handle = require("frient.can_handle"), } return frient_water_leak_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/init.lua index 68a218a586..6f4b879108 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -89,16 +79,9 @@ local zigbee_water_driver_template = { added = added_handler }, ias_zone_configuration_method = constants.IAS_ZONE_CONFIGURE_TYPE.AUTO_ENROLL_RESPONSE, - sub_drivers = { - require("aqara"), - require("zigbee-water-freeze"), - require("leaksmart"), - require("frient"), - require("thirdreality"), - require("sengled"), - require("sinope") - }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_water_driver_template, diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/can_handle.lua new file mode 100644 index 0000000000..67c6f425ca --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function leaksmart_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "WAXMAN" and device:get_model() == "leakSMART Water Sensor V2" then + return true, require("leaksmart") + end + return false +end + +return leaksmart_can_handle diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/init.lua index 010379df0f..a8ab5212b5 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/leaksmart/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -58,9 +48,7 @@ local leaksmart_water_sensor = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "WAXMAN" and device:get_model() == "leakSMART Water Sensor V2" - end + can_handle = require("leaksmart.can_handle"), } return leaksmart_water_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/can_handle.lua new file mode 100644 index 0000000000..68d774fb8f --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sengled_products = function(opts, driver, device, ...) + local FINGERPRINTS = require("sengled.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sengled") + end + end + return false +end + +return is_sengled_products diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/fingerprints.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/fingerprints.lua new file mode 100644 index 0000000000..2c3f527da6 --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "sengled", model = "E1L-G7K" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/init.lua index f00263e309..0010073033 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sengled/init.lua @@ -1,12 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local battery_defaults = require "st.zigbee.defaults.battery_defaults" local IASZone = clusters.IASZone local PowerConfiguration = clusters.PowerConfiguration -local FINGERPRINTS = { - { mfr = "sengled", model = "E1L-G7K" } -} local CONFIGURATIONS = { { @@ -27,14 +27,6 @@ local CONFIGURATIONS = { } } -local is_sengled_products = function(opts, driver, device, ...) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_init(driver, device) battery_defaults.build_linear_voltage_init(2.6, 3.0)(driver, device) @@ -49,7 +41,7 @@ local sengled_water_leak_sensor_handler = { lifecycle_handlers = { init = device_init }, - can_handle = is_sengled_products + can_handle = require("sengled.can_handle"), } return sengled_water_leak_sensor_handler diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/can_handle.lua new file mode 100644 index 0000000000..757e3ca0db --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_sinope_water_sensor = function(opts, driver, device) + local SINOPE_TECHNOLOGIES_MFR_STRING = "Sinope Technologies" + if device:get_manufacturer() == SINOPE_TECHNOLOGIES_MFR_STRING then + return true, require("sinope") + else + return false + end +end + +return is_sinope_water_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/init.lua index 65cc802697..7bf6f8be82 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sinope/init.lua @@ -1,23 +1,11 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local IASZone = zcl_clusters.IASZone -local SINOPE_TECHNOLOGIES_MFR_STRING = "Sinope Technologies" - local generate_event_from_zone_status = function(driver, device, zone_status, zb_rx) local event if zone_status:is_alarm1_set() then @@ -39,13 +27,6 @@ local ias_zone_status_change_handler = function(driver, device, zb_rx) generate_event_from_zone_status(driver, device, zb_rx.body.zcl_body.zone_status, zb_rx) end -local is_sinope_water_sensor = function(opts, driver, device) - if device:get_manufacturer() == SINOPE_TECHNOLOGIES_MFR_STRING then - return true - else - return false - end -end local sinope_water_sensor = { NAME = "Sinope Water Leak Sensor", @@ -61,7 +42,7 @@ local sinope_water_sensor = { } } }, - can_handle = is_sinope_water_sensor + can_handle = require("sinope.can_handle"), } -return sinope_water_sensor \ No newline at end of file +return sinope_water_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/sub_drivers.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..79d572579e --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/sub_drivers.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara"), + lazy_load_if_possible("zigbee-water-freeze"), + lazy_load_if_possible("leaksmart"), + lazy_load_if_possible("frient"), + lazy_load_if_possible("thirdreality"), + lazy_load_if_possible("sengled"), + lazy_load_if_possible("sinope"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_aqara_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_aqara_water_leak_sensor.lua index 495a8455b3..06f558bdcd 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_aqara_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_aqara_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -61,7 +51,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_ATTRIBUTE_ID, MFG_CODE , data_types.Uint8, 1) }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -85,6 +78,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -109,6 +105,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -125,6 +124,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_centralite_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_centralite_water_leak_sensor.lua index 4ee674199c..a5acbfb81e 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_centralite_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_centralite_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,7 +46,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -115,7 +108,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -131,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -147,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -163,6 +165,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -179,6 +184,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -203,6 +211,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -227,6 +238,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -256,6 +270,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_frient_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_frient_water_leak_sensor.lua index 13bdb14268..6c0881c715 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_frient_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_frient_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,7 +46,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -121,7 +114,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -137,6 +133,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -153,6 +152,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -169,6 +171,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -185,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -209,6 +217,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -233,6 +244,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -262,6 +276,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_leaksmart_water.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_leaksmart_water.lua index 7b20d7c3bb..47c17f4c2f 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_leaksmart_water.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_leaksmart_water.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -50,7 +40,10 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x001181}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.wet())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -59,7 +52,10 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x000081}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.dry())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -68,7 +64,10 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x000581}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.dry())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -77,7 +76,10 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x001081}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.dry())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -86,30 +88,24 @@ test.register_coroutine_test( local alert_command = ApplianceEventsAlerts.client.commands.AlertsNotification.build_test_rx(mock_device, 0x01, {0x001281}) test.socket.zigbee:__queue_receive({ mock_device.id, alert_command }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.waterSensor.water.dry())) - end + end, + { + min_api_version = 14 + } ) --- test.register_coroutine_test( --- "Health check should check all relevant attributes", --- function() --- test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) --- test.socket.capability:__expect_send( --- { --- mock_device.id, --- { --- capability_id = "waterSensor", component_id = "main", --- attribute_id = "water", state={value="dry"} --- } --- } --- ) --- end, --- { --- test_init = function() --- test.mock_device.add_test_device(mock_device) --- test.timer.__create_and_queue_test_time_advance_timer(30, "interval", "health_check") --- end --- } --- ) +test.register_coroutine_test( + "Added lifecycle should emit water dry event", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) + ) + end, + { + min_api_version = 14 + } +) test.register_coroutine_test( "Configure should configure all necessary attributes", @@ -171,7 +167,10 @@ test.register_coroutine_test( IASZone.attributes.ZoneStatus:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_samjin_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_samjin_water_leak_sensor.lua index 43543e3127..bc941ebfdb 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_samjin_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_samjin_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,7 +46,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -115,7 +108,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -131,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -147,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) } + }, + { + min_api_version = 14 } ) @@ -171,6 +173,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -195,6 +200,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -224,6 +232,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sengled_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sengled_water_leak_sensor.lua index d4ad7bccff..5c2764ebe7 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sengled_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sengled_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -53,7 +43,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -101,7 +94,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sinope_zigbee_water.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sinope_zigbee_water.lua index bc476f06bb..c7b8f35247 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sinope_zigbee_water.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_sinope_zigbee_water.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -56,6 +46,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -72,6 +65,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -88,6 +84,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -104,6 +103,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -120,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -144,6 +149,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -160,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -300,7 +311,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -361,7 +375,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_smartthings_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_smartthings_water_leak_sensor.lua index e4ffe5567c..bde19b9206 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_smartthings_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_smartthings_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" @@ -56,7 +46,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryVoltage:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -115,7 +108,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, TemperatureMeasurement.attributes.MeasuredValue:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -145,7 +141,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -169,6 +168,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -193,6 +195,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -222,6 +227,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_thirdreality_water_leak_sensor.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_thirdreality_water_leak_sensor.lua index d32f94e255..8056969142 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_thirdreality_water_leak_sensor.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_thirdreality_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -46,6 +36,17 @@ end test.set_test_init_function(test_init) +test.register_coroutine_test( + "Added lifecycle should read ApplicationVersion", + function() + test.socket.device_lifecycle:__queue_receive({mock_device.id, "added"}) + test.socket.zigbee:__expect_send({mock_device.id, Basic.attributes.ApplicationVersion:read(mock_device)}) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Refresh necessary attributes", function() @@ -53,7 +54,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) test.socket.zigbee:__expect_send({ mock_device.id, IASZone.attributes.ZoneStatus:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -75,7 +79,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(55)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -97,7 +104,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(55)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -119,7 +129,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -141,7 +154,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water.lua index ab0163ef5c..bda51e2655 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -54,6 +44,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -78,6 +71,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -102,6 +98,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -126,6 +125,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "waterSensor", capability_attr_id = "water" } } }, + }, + { + min_api_version = 14 } ) @@ -150,6 +152,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -177,6 +182,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -193,6 +201,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) } + }, + { + min_api_version = 14 } ) @@ -333,7 +344,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -394,7 +408,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water_freeze.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water_freeze.lua index 3186af8245..a718ea0b75 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water_freeze.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/test/test_zigbee_water_freeze.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -83,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = -25.0, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -104,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = 20.00, maximum = 30.00 }, unit = "C" })) } + }, + { + min_api_version = 14 } ) @@ -225,7 +224,10 @@ test.register_coroutine_test( }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -286,7 +288,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/can_handle.lua new file mode 100644 index 0000000000..044050b14f --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_third_reality_water_leak_sensor(opts, driver, device) + local FINGERPRINTS = require("thirdreality.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("thirdreality") + end + end + return false +end + +return can_handle_third_reality_water_leak_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/fingerprints.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/fingerprints.lua new file mode 100644 index 0000000000..f56a027f4f --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local THIRD_REALITY_WATER_LEAK_SENSOR_FINGERPRINTS = { + { mfr = "Third Reality, Inc", model = "3RWS18BZ"}, + { mfr = "THIRDREALITY", model = "3RWS18BZ"} +} + +return THIRD_REALITY_WATER_LEAK_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/init.lua index 99f14294e9..8a3819ac44 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/thirdreality/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local utils = require "st.utils" @@ -20,19 +10,7 @@ local PowerConfiguration = zcl_clusters.PowerConfiguration local APPLICATION_VERSION = "application_version" -local THIRD_REALITY_WATER_LEAK_SENSOR_FINGERPRINTS = { - { mfr = "Third Reality, Inc", model = "3RWS18BZ"}, - { mfr = "THIRDREALITY", model = "3RWS18BZ"} -} -local function can_handle_third_reality_water_leak_sensor(opts, driver, device) - for _, fingerprint in ipairs(THIRD_REALITY_WATER_LEAK_SENSOR_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function device_added(driver, device) device:set_field(APPLICATION_VERSION, 0) @@ -73,7 +51,7 @@ local third_reality_water_leak_sensor = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle_third_reality_water_leak_sensor + can_handle = require("thirdreality.can_handle"), } -return third_reality_water_leak_sensor \ No newline at end of file +return third_reality_water_leak_sensor diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/can_handle.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/can_handle.lua new file mode 100644 index 0000000000..5850a8b11c --- /dev/null +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function zigbee_water_freeze_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Ecolink" and device:get_model() == "FLZB1-ECO" then + return true, require("zigbee-water-freeze") + end + return false +end + +return zigbee_water_freeze_can_handle diff --git a/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/init.lua b/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/init.lua index 68bd2ba9e0..82aca1b710 100644 --- a/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/init.lua +++ b/drivers/SmartThings/zigbee-water-leak-sensor/src/zigbee-water-freeze/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local device_management = require "st.zigbee.device_management" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -68,9 +58,7 @@ local zigbee_water_freeze = { init = battery_defaults.build_linear_voltage_init(2.2, 3.0), doConfigure = do_configure }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Ecolink" and device:get_model() == "FLZB1-ECO" - end + can_handle = require("zigbee-water-freeze.can_handle"), } return zigbee_water_freeze diff --git a/drivers/SmartThings/zigbee-watering-kit/src/init.lua b/drivers/SmartThings/zigbee-watering-kit/src/init.lua index f39c04beaa..7e23c2fef9 100644 --- a/drivers/SmartThings/zigbee-watering-kit/src/init.lua +++ b/drivers/SmartThings/zigbee-watering-kit/src/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" local defaults = require "st.zigbee.defaults" @@ -10,10 +13,9 @@ local zigbee_water_driver_template = { capabilities.fanSpeed, capabilities.mode }, - sub_drivers = { - require("thirdreality") - }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_water_driver_template, zigbee_water_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-watering-kit/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-watering-kit/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-watering-kit/src/sub_drivers.lua b/drivers/SmartThings/zigbee-watering-kit/src/sub_drivers.lua new file mode 100644 index 0000000000..e33b31c978 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("thirdreality"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-watering-kit/src/test/test_thirdreality_watering_kit.lua b/drivers/SmartThings/zigbee-watering-kit/src/test/test_thirdreality_watering_kit.lua index 35ab4d915c..fdb72d3d0c 100644 --- a/drivers/SmartThings/zigbee-watering-kit/src/test/test_thirdreality_watering_kit.lua +++ b/drivers/SmartThings/zigbee-watering-kit/src/test/test_thirdreality_watering_kit.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -43,7 +46,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(10))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.mode("0"))) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -59,6 +65,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) } + }, + { + min_api_version = 14 } ) @@ -75,6 +84,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -91,6 +103,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -107,6 +122,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.On(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -123,6 +141,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, OnOff.server.commands.Off(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -139,6 +160,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) } + }, + { + min_api_version = 14 } ) @@ -155,6 +179,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) } + }, + { + min_api_version = 14 } ) @@ -169,7 +196,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, THIRDREALITY_WATERING_CLUSTER, attr_report_data, 0x1407) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(10))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -183,7 +213,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, THIRDREALITY_WATERING_CLUSTER, attr_report_data, 0x1407) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(30))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -197,7 +230,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_report(mock_device, THIRDREALITY_WATERING_CLUSTER, attr_report_data, 0x1407) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.mode("4"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -206,7 +242,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "fanSpeed", component = "main", command = "setFanSpeed", args = { 20 } } }) test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, THIRDREALITY_WATERING_CLUSTER, WATERING_TIME_ATTR, 0x1407, data_types.Uint16, 20) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -215,7 +254,65 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({ mock_device.id, { capability = "mode", component = "main", command = "setMode", args = { "2" } } }) test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, THIRDREALITY_WATERING_CLUSTER, WATERING_INTERVAL_ATTR, 0x1407, data_types.Uint8, 2) }) - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: detected", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0001, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.detected()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "ZoneStatusChangeNotification should be handled: clear", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, IASZone.client.commands.ZoneStatusChangeNotification.build_test_rx(mock_device, 0x0000, 0x00) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.hardwareFault.hardwareFault.clear()) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "fanspeed reported should be clamped to 0 when value >= 1000", + function() + local attr_report_data = { + { WATERING_TIME_ATTR, data_types.Uint16.ID, 1000 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, THIRDREALITY_WATERING_CLUSTER, attr_report_data, 0x1407) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.fanSpeed.fanSpeed(0))) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/can_handle.lua b/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/can_handle.lua new file mode 100644 index 0000000000..12afaa7d98 --- /dev/null +++ b/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function thirdreality_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "Third Reality, Inc" and device:get_model() == "3RWK0148Z" then + return true, require("thirdreality") + end + return false +end + +return thirdreality_can_handle diff --git a/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/init.lua b/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/init.lua index 5f73f3f9ca..5e36e15624 100644 --- a/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/init.lua +++ b/drivers/SmartThings/zigbee-watering-kit/src/thirdreality/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" local IASZone = clusters.IASZone @@ -97,9 +100,7 @@ local thirdreality_device_handler = { lifecycle_handlers = { added = device_added }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "Third Reality, Inc" and device:get_model() == "3RWK0148Z" - end + can_handle = require("thirdreality.can_handle"), } return thirdreality_device_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml index e15dd6f3a2..021d59d2d4 100644 --- a/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml +++ b/drivers/SmartThings/zigbee-window-treatment/fingerprints.yml @@ -138,6 +138,21 @@ zigbeeManufacturer: manufacturer: HOPOsmart model: A2230011 deviceProfileName: window-shade-only + - id: "Sombra Shades/WM25/L-Z" + deviceLabel: Sombra Shades Window Treatment + manufacturer: Sombra Shades + model: WM25/L-Z + deviceProfileName: window-treatment-battery + - id: "Sombra Shades/SS25/L-Z" + deviceLabel: Sombra Automated Shades and Blinds + manufacturer: Sombra Shades + model: SS25/L-Z + deviceProfileName: window-treatment-battery + - id: "Sombra Shades/SOMBRA/Z-M" + deviceLabel: Sombra Automated Shades + manufacturer: Sombra Shades + model: SOMBRA/Z-M + deviceProfileName: window-treatment-powerSource zigbeeGeneric: - id: "genericShade" diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml index dbd219e523..05e3fe0085 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: stse.initializedStateWithGuide version: 1 - id: stse.hookLockState diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml index c454c4d590..e7f5e8d8af 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: stse.initializedStateWithGuide version: 1 - id: stse.shadeRotateState diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml index 6c2cc967e7..4dc959ef93 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: stse.deviceInitialization version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml index be0ae73d76..e9b6178ff8 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml index 7a65434c46..67ba3c186d 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml index 7323ee0218..d72dc33308 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: windowShadePreset version: 1 - id: powerSource diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml index 8e641e6b42..dfeeed7aa1 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml index b10a5ea101..c9fce872c0 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml index 0eb666a5d2..54b4312210 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: windowShadePreset version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/can_handle.lua new file mode 100644 index 0000000000..85db6c0447 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("HOPOsmart.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("HOPOsmart") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/custom_clusters.lua b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/custom_clusters.lua index 1f2950b2f1..b0394aa3fe 100755 --- a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/custom_clusters.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/custom_clusters.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" diff --git a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/fingerprints.lua new file mode 100644 index 0000000000..abf5e54ae5 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "HOPOsmart", model = "A2230011" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/init.lua index 5e3f002ec0..3267eefc33 100755 --- a/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/HOPOsmart/init.lua @@ -1,34 +1,13 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local custom_clusters = require "HOPOsmart/custom_clusters" local cluster_base = require "st.zigbee.cluster_base" -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "HOPOsmart", model = "A2230011" } -} -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function send_read_attr_request(device, cluster, attr) device:send( @@ -83,7 +62,7 @@ local HOPOsmart_handler = { } } }, - can_handle = is_zigbee_window_shade, + can_handle = require("HOPOsmart.can_handle"), } return HOPOsmart_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/can_handle.lua new file mode 100644 index 0000000000..d6907690c3 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("VIVIDSTORM.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("VIVIDSTORM") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/custom_clusters.lua b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/custom_clusters.lua index 6f266a474e..8efbc0e654 100755 --- a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/custom_clusters.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/custom_clusters.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local data_types = require "st.zigbee.data_types" diff --git a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/fingerprints.lua new file mode 100644 index 0000000000..ff1bbcb00d --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "VIVIDSTORM", model = "VWSDSTUST120H" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/init.lua index ef36757490..106115edee 100755 --- a/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/VIVIDSTORM/init.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zcl_clusters = require "st.zigbee.zcl.clusters" local capabilities = require "st.capabilities" @@ -22,18 +12,7 @@ local MOST_RECENT_SETLEVEL = "windowShade_recent_setlevel" local TIMER = "liftPercentage_timer" -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "VIVIDSTORM", model = "VWSDSTUST120H" } -} -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function send_read_attr_request(device, cluster, attr) device:send( @@ -156,7 +135,7 @@ local screen_handler = { } } }, - can_handle = is_zigbee_window_shade, + can_handle = require("VIVIDSTORM.can_handle"), } return screen_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/can_handle.lua new file mode 100644 index 0000000000..e4453597ed --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_aqara_products(opts, driver, device) + local FINGERPRINTS = require("aqara.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("aqara") + end + end + return false +end + +return is_aqara_products diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/can_handle.lua new file mode 100644 index 0000000000..7eb40a7c10 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function curtain_driver_e1_can_handle(opts, driver, device, ...) + if device:get_model() == "lumi.curtain.agl001" then + return true, require("aqara.curtain-driver-e1") + end + return false +end + +return curtain_driver_e1_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/init.lua index 5d4f4d3a37..03e651b679 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/curtain-driver-e1/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -29,10 +19,10 @@ local PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID = 0x0427 local PRIVATE_CURTAIN_LOCKING_STATUS_ATTRIBUTE_ID = 0x0428 local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"] -local reverseCurtainDirection = capabilities["stse.reverseCurtainDirection"] +local reverseCurtainDirection = "stse.reverseCurtainDirection" local hookLockState = capabilities["stse.hookLockState"] local chargingState = capabilities["stse.chargingState"] -local softTouch = capabilities["stse.softTouch"] +local softTouch = "stse.softTouch" local hookUnlockCommandName = "hookUnlock" local hookLockCommandName = "hookLock" @@ -82,12 +72,12 @@ end local function device_info_changed(driver, device, event, args) if device.preferences ~= nil then - local reverseCurtainDirectionPrefValue = device.preferences[reverseCurtainDirection.ID] - local softTouchPrefValue = device.preferences[softTouch.ID] + local reverseCurtainDirectionPrefValue = device.preferences[reverseCurtainDirection] + local softTouchPrefValue = device.preferences[softTouch] -- reverse direction if reverseCurtainDirectionPrefValue ~= nil and - reverseCurtainDirectionPrefValue ~= args.old_st_store.preferences[reverseCurtainDirection.ID] then + reverseCurtainDirectionPrefValue ~= args.old_st_store.preferences[reverseCurtainDirection] then local raw_value = reverseCurtainDirectionPrefValue and 0x01 or 0x00 device:send(aqara_utils.custom_write_attribute(device, WindowCovering.ID, WindowCovering.attributes.Mode.ID, data_types.Bitmap8, raw_value, nil)) @@ -95,7 +85,7 @@ local function device_info_changed(driver, device, event, args) -- soft touch if softTouchPrefValue ~= nil and - softTouchPrefValue ~= args.old_st_store.preferences[softTouch.ID] then + softTouchPrefValue ~= args.old_st_store.preferences[softTouch] then device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_MANUAL_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Boolean, (not softTouchPrefValue))) end @@ -224,9 +214,7 @@ local aqara_curtain_driver_e1_handler = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "lumi.curtain.agl001" - end + can_handle = require("aqara.curtain-driver-e1.can_handle"), } return aqara_curtain_driver_e1_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/fingerprints.lua new file mode 100644 index 0000000000..8ad05530a5 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.curtain" }, + { mfr = "LUMI", model = "lumi.curtain.v1" }, + { mfr = "LUMI", model = "lumi.curtain.aq2" }, + { mfr = "LUMI", model = "lumi.curtain.agl001" } +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua index ae9513734c..1b66236038 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua @@ -1,3 +1,7 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" local cluster_base = require "st.zigbee.cluster_base" @@ -11,8 +15,8 @@ local AnalogOutput = clusters.AnalogOutput local Groups = clusters.Groups local deviceInitialization = capabilities["stse.deviceInitialization"] -local reverseCurtainDirection = capabilities["stse.reverseCurtainDirection"] -local softTouch = capabilities["stse.softTouch"] +local reverseCurtainDirection = "stse.reverseCurtainDirection" +local softTouch = "stse.softTouch" local setInitializedStateCommandName = "setInitializedState" local INIT_STATE = "initState" @@ -27,21 +31,7 @@ local PREF_SOFT_TOUCH_ON = "\x00\x08\x00\x00\x00\x00\x00" local APPLICATION_VERSION = "application_version" -local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.curtain" }, - { mfr = "LUMI", model = "lumi.curtain.v1" }, - { mfr = "LUMI", model = "lumi.curtain.aq2" }, - { mfr = "LUMI", model = "lumi.curtain.agl001" } -} -local function is_aqara_products(opts, driver, device) - for _, fingerprint in ipairs(FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function window_shade_level_cmd(driver, device, command) aqara_utils.shade_level_cmd(driver, device, command) @@ -133,12 +123,12 @@ end local function device_info_changed(driver, device, event, args) if device.preferences ~= nil then - local reverseCurtainDirectionPrefValue = device.preferences[reverseCurtainDirection.ID] - local softTouchPrefValue = device.preferences[softTouch.ID] + local reverseCurtainDirectionPrefValue = device.preferences[reverseCurtainDirection] + local softTouchPrefValue = device.preferences[softTouch] -- reverse direction if reverseCurtainDirectionPrefValue ~= nil and - reverseCurtainDirectionPrefValue ~= args.old_st_store.preferences[reverseCurtainDirection.ID] then + reverseCurtainDirectionPrefValue ~= args.old_st_store.preferences[reverseCurtainDirection] then local raw_value = reverseCurtainDirectionPrefValue and aqara_utils.PREF_REVERSE_ON or aqara_utils.PREF_REVERSE_OFF device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.CharString, raw_value)) @@ -152,7 +142,7 @@ local function device_info_changed(driver, device, event, args) -- soft touch if softTouchPrefValue ~= nil and - softTouchPrefValue ~= args.old_st_store.preferences[softTouch.ID] then + softTouchPrefValue ~= args.old_st_store.preferences[softTouch] then local raw_value = softTouchPrefValue and PREF_SOFT_TOUCH_ON or PREF_SOFT_TOUCH_OFF device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.CharString, raw_value)) @@ -217,12 +207,8 @@ local aqara_window_treatment_handler = { } } }, - sub_drivers = { - require("aqara.roller-shade"), - require("aqara.curtain-driver-e1"), - require("aqara.version") - }, - can_handle = is_aqara_products + sub_drivers = require("aqara.sub_drivers"), + can_handle = require("aqara.can_handle"), } return aqara_window_treatment_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/can_handle.lua new file mode 100644 index 0000000000..2e91fa090d --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function roller_shade_can_handle(opts, driver, device, ...) + if device:get_model() == "lumi.curtain.aq2" then + return true, require("aqara.roller-shade") + end + return false +end + +return roller_shade_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/init.lua index c7b6b9a50a..909b65dd3e 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/roller-shade/init.lua @@ -1,141 +1,142 @@ -local capabilities = require "st.capabilities" -local clusters = require "st.zigbee.zcl.clusters" -local cluster_base = require "st.zigbee.cluster_base" -local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" -local data_types = require "st.zigbee.data_types" -local aqara_utils = require "aqara/aqara_utils" -local window_treatment_utils = require "window_treatment_utils" - -local Basic = clusters.Basic -local WindowCovering = clusters.WindowCovering - -local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"] -local reverseRollerShadeDir = capabilities["stse.reverseRollerShadeDir"] -local shadeRotateState = capabilities["stse.shadeRotateState"] -local setRotateStateCommandName = "setRotateState" - -local MULTISTATE_CLUSTER_ID = 0x0013 -local MULTISTATE_ATTRIBUTE_ID = 0x0055 -local ROTATE_UP_VALUE = 0x0004 -local ROTATE_DOWN_VALUE = 0x0005 - - -local function window_shade_level_cmd(driver, device, command) - -- Cannot be controlled if not initialized - local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, - initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 - if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then - aqara_utils.shade_level_cmd(driver, device, command) - end -end - -local function window_shade_open_cmd(driver, device, command) - -- Cannot be controlled if not initialized - local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, - initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 - if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then - device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 100)) - end -end - -local function window_shade_close_cmd(driver, device, command) - -- Cannot be controlled if not initialized - local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, - initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 - if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then - device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 0)) - end -end - -local function set_rotate_command_handler(driver, device, command) - device:emit_event(shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }})) -- update UI - - -- Cannot be controlled if not initialized - local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, - initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 - if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then - local state = command.args.state - if state == "rotateUp" then - local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID, - MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_UP_VALUE) - message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) - device:send(message) - elseif state == "rotateDown" then - local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID, - MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_DOWN_VALUE) - message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) - device:send(message) - end - end -end - -local function shade_state_report_handler(driver, device, value, zb_rx) - aqara_utils.emit_shade_event_by_state(device, value) -end - -local function pref_report_handler(driver, device, value, zb_rx) - -- initializedState - local initialized = string.byte(value.value, 3) & 0xFF - device:emit_event(initialized == 1 and initializedStateWithGuide.initializedStateWithGuide.initialized() or - initializedStateWithGuide.initializedStateWithGuide.notInitialized()) -end - -local function device_info_changed(driver, device, event, args) - if device.preferences ~= nil then - local reverseRollerShadeDirPrefValue = device.preferences[reverseRollerShadeDir.ID] - if reverseRollerShadeDirPrefValue ~= nil and - reverseRollerShadeDirPrefValue ~= args.old_st_store.preferences[reverseRollerShadeDir.ID] then - local raw_value = reverseRollerShadeDirPrefValue and aqara_utils.PREF_REVERSE_ON or aqara_utils.PREF_REVERSE_OFF - device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, - aqara_utils.MFG_CODE, data_types.CharString, raw_value)) - end - end -end - -local function device_added(driver, device) - device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) - window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShadeLevel, capabilities.windowShadeLevel.shadeLevel.NAME, capabilities.windowShadeLevel.shadeLevel(0)) - window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShade, capabilities.windowShade.windowShade.NAME, capabilities.windowShade.windowShade.closed()) - device:emit_event(initializedStateWithGuide.initializedStateWithGuide.notInitialized()) - device:emit_event(shadeRotateState.rotateState.idle({ visibility = { displayed = false }})) - - device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, - aqara_utils.PRIVATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint8, 1)) - - -- Initial default settings - device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, - aqara_utils.MFG_CODE, data_types.CharString, aqara_utils.PREF_REVERSE_OFF)) -end - -local aqara_roller_shade_handler = { - NAME = "Aqara Roller Shade Handler", - lifecycle_handlers = { - added = device_added, - infoChanged = device_info_changed - }, - capability_handlers = { - [capabilities.windowShadeLevel.ID] = { - [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd - }, - [capabilities.windowShade.ID] = { - [capabilities.windowShade.commands.open.NAME] = window_shade_open_cmd, - [capabilities.windowShade.commands.close.NAME] = window_shade_close_cmd, - }, - [shadeRotateState.ID] = { - [setRotateStateCommandName] = set_rotate_command_handler - } - }, - zigbee_handlers = { - attr = { - [Basic.ID] = { - [aqara_utils.SHADE_STATE_ATTRIBUTE_ID] = shade_state_report_handler, - [aqara_utils.PREF_ATTRIBUTE_ID] = pref_report_handler - } - } - }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "lumi.curtain.aq2" - end -} - -return aqara_roller_shade_handler +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local FrameCtrl = require "st.zigbee.zcl.frame_ctrl" +local data_types = require "st.zigbee.data_types" +local aqara_utils = require "aqara/aqara_utils" +local window_treatment_utils = require "window_treatment_utils" + +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering + +local initializedStateWithGuide = capabilities["stse.initializedStateWithGuide"] +local reverseRollerShadeDir = capabilities["stse.reverseRollerShadeDir"] +local shadeRotateState = capabilities["stse.shadeRotateState"] +local setRotateStateCommandName = "setRotateState" + +local MULTISTATE_CLUSTER_ID = 0x0013 +local MULTISTATE_ATTRIBUTE_ID = 0x0055 +local ROTATE_UP_VALUE = 0x0004 +local ROTATE_DOWN_VALUE = 0x0005 + + +local function window_shade_level_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + aqara_utils.shade_level_cmd(driver, device, command) + end +end + +local function window_shade_open_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 100)) + end +end + +local function window_shade_close_cmd(driver, device, command) + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, 0)) + end +end + +local function set_rotate_command_handler(driver, device, command) + device:emit_event(shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }})) -- update UI + + -- Cannot be controlled if not initialized + local initialized = device:get_latest_state("main", initializedStateWithGuide.ID, + initializedStateWithGuide.initializedStateWithGuide.NAME) or 0 + if initialized == initializedStateWithGuide.initializedStateWithGuide.initialized.NAME then + local state = command.args.state + if state == "rotateUp" then + local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID, + MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_UP_VALUE) + message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) + device:send(message) + elseif state == "rotateDown" then + local message = cluster_base.write_manufacturer_specific_attribute(device, MULTISTATE_CLUSTER_ID, + MULTISTATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint16, ROTATE_DOWN_VALUE) + message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) + device:send(message) + end + end +end + +local function shade_state_report_handler(driver, device, value, zb_rx) + aqara_utils.emit_shade_event_by_state(device, value) +end + +local function pref_report_handler(driver, device, value, zb_rx) + -- initializedState + local initialized = string.byte(value.value, 3) & 0xFF + device:emit_event(initialized == 1 and initializedStateWithGuide.initializedStateWithGuide.initialized() or + initializedStateWithGuide.initializedStateWithGuide.notInitialized()) +end + +local function device_info_changed(driver, device, event, args) + if device.preferences ~= nil then + local reverseRollerShadeDirPrefValue = device.preferences[reverseRollerShadeDir.ID] + if reverseRollerShadeDirPrefValue ~= nil and + reverseRollerShadeDirPrefValue ~= args.old_st_store.preferences[reverseRollerShadeDir.ID] then + local raw_value = reverseRollerShadeDirPrefValue and aqara_utils.PREF_REVERSE_ON or aqara_utils.PREF_REVERSE_OFF + device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, + aqara_utils.MFG_CODE, data_types.CharString, raw_value)) + end + end +end + +local function device_added(driver, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShadeLevel, capabilities.windowShadeLevel.shadeLevel.NAME, capabilities.windowShadeLevel.shadeLevel(0)) + window_treatment_utils.emit_event_if_latest_state_missing(device, "main", capabilities.windowShade, capabilities.windowShade.windowShade.NAME, capabilities.windowShade.windowShade.closed()) + device:emit_event(initializedStateWithGuide.initializedStateWithGuide.notInitialized()) + device:emit_event(shadeRotateState.rotateState.idle({ visibility = { displayed = false }})) + + device:send(cluster_base.write_manufacturer_specific_attribute(device, aqara_utils.PRIVATE_CLUSTER_ID, + aqara_utils.PRIVATE_ATTRIBUTE_ID, aqara_utils.MFG_CODE, data_types.Uint8, 1)) + + -- Initial default settings + device:send(cluster_base.write_manufacturer_specific_attribute(device, Basic.ID, aqara_utils.PREF_ATTRIBUTE_ID, + aqara_utils.MFG_CODE, data_types.CharString, aqara_utils.PREF_REVERSE_OFF)) +end + +local aqara_roller_shade_handler = { + NAME = "Aqara Roller Shade Handler", + lifecycle_handlers = { + added = device_added, + infoChanged = device_info_changed + }, + capability_handlers = { + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd + }, + [capabilities.windowShade.ID] = { + [capabilities.windowShade.commands.open.NAME] = window_shade_open_cmd, + [capabilities.windowShade.commands.close.NAME] = window_shade_close_cmd, + }, + [shadeRotateState.ID] = { + [setRotateStateCommandName] = set_rotate_command_handler + } + }, + zigbee_handlers = { + attr = { + [Basic.ID] = { + [aqara_utils.SHADE_STATE_ATTRIBUTE_ID] = shade_state_report_handler, + [aqara_utils.PREF_ATTRIBUTE_ID] = pref_report_handler + } + } + }, + can_handle = require("aqara.roller-shade.can_handle"), +} + +return aqara_roller_shade_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/sub_drivers.lua new file mode 100644 index 0000000000..297f29d970 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aqara.roller-shade"), + lazy_load_if_possible("aqara.curtain-driver-e1"), + lazy_load_if_possible("aqara.version"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/can_handle.lua new file mode 100644 index 0000000000..5d9f7f0135 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function version_can_handle(opts, driver, device) + local APPLICATION_VERSION = "application_version" + local softwareVersion = device:get_field(APPLICATION_VERSION) + if softwareVersion and softwareVersion ~= 34 then + return true, require("aqara.version") + end + return false +end + +return version_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/init.lua index 10182ee928..ae1887d79a 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/version/init.lua @@ -1,9 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local clusters = require "st.zigbee.zcl.clusters" local WindowCovering = clusters.WindowCovering -local APPLICATION_VERSION = "application_version" - local function shade_level_report_legacy_handler(driver, device, value, zb_rx) -- not implemented end @@ -17,10 +18,7 @@ local aqara_window_treatment_version_handler = { } } }, - can_handle = function(opts, driver, device) - local softwareVersion = device:get_field(APPLICATION_VERSION) - return softwareVersion and softwareVersion ~= 34 - end + can_handle = require("aqara.version.can_handle"), } return aqara_window_treatment_version_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/can_handle.lua new file mode 100644 index 0000000000..828eb170fa --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_axis_gear_version = function(opts, driver, device) + local SOFTWARE_VERSION = "software_version" + local MIN_WINDOW_COVERING_VERSION = 1093 + local version = device:get_field(SOFTWARE_VERSION) or 0 + + if version >= MIN_WINDOW_COVERING_VERSION then + return true, require("axis.axis_version") + end + return false +end + +return is_axis_gear_version diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/init.lua index c0682d73c0..cbb2930bc3 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/axis_version/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local window_shade_utils = require "window_shade_utils" @@ -22,18 +12,8 @@ local Level = zcl_clusters.Level local PowerConfiguration = zcl_clusters.PowerConfiguration local WindowCovering = zcl_clusters.WindowCovering -local SOFTWARE_VERSION = "software_version" -local MIN_WINDOW_COVERING_VERSION = 1093 local DEFAULT_LEVEL = 0 -local is_axis_gear_version = function(opts, driver, device) - local version = device:get_field(SOFTWARE_VERSION) or 0 - - if version >= MIN_WINDOW_COVERING_VERSION then - return true - end - return false -end -- Commands local function window_shade_set_level(device, command, level) @@ -141,7 +121,7 @@ local axis_handler_version = { } } }, - can_handle = is_axis_gear_version, + can_handle = require("axis.axis_version.can_handle"), } return axis_handler_version diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/can_handle.lua new file mode 100644 index 0000000000..049e47acb5 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + if device:get_manufacturer() == "AXIS" then + return true, require("axis") + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/init.lua index a47de06df7..ec7c96b975 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/axis/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" local device_management = require "st.zigbee.device_management" @@ -26,12 +17,6 @@ local WindowCovering = zcl_clusters.WindowCovering local SOFTWARE_VERSION = "software_version" local DEFAULT_LEVEL = 0 -local is_zigbee_window_shade = function(opts, driver, device) - if device:get_manufacturer() == "AXIS" then - return true - end - return false -end -- Commands local function window_shade_set_level(device, command, level) @@ -110,6 +95,7 @@ local do_configure = function(self, device) device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(device, 1, 3600, 1)) device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 1, 3600, 1)) + device:refresh() end local device_added = function(self, device) @@ -150,8 +136,8 @@ local axis_handler = { added = device_added, doConfigure = do_configure, }, - sub_drivers = { require("axis.axis_version") }, - can_handle = is_zigbee_window_shade, + sub_drivers = require("axis.sub_drivers"), + can_handle = require("axis.can_handle"), } return axis_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/axis/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/axis/sub_drivers.lua new file mode 100644 index 0000000000..e3ea740478 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/axis/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require("lazy_load_subdriver") + +return { + lazy_load_if_possible("axis.axis_version") +} diff --git a/drivers/SmartThings/zigbee-window-treatment/src/feibit/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/feibit/can_handle.lua new file mode 100644 index 0000000000..32457dde8a --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/feibit/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("feibit.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("feibit") + end + end + + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/feibit/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/feibit/fingerprints.lua new file mode 100644 index 0000000000..95781ff992 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/feibit/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Feibit Co.Ltd", model = "FTB56-ZT218AK1.6" }, + { mfr = "Feibit Co.Ltd", model = "FTB56-ZT218AK1.8" }, +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/feibit/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/feibit/init.lua index f33bf81cbd..1e97fbc4ce 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/feibit/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/feibit/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -19,20 +9,7 @@ local window_shade_defaults = require "st.zigbee.defaults.windowShade_defaults" local device_management = require "st.zigbee.device_management" local Level = zcl_clusters.Level -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Feibit Co.Ltd", model = "FTB56-ZT218AK1.6" }, - { mfr = "Feibit Co.Ltd", model = "FTB56-ZT218AK1.8" }, -} - -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function set_shade_level(device, value, component) local level = math.floor(value / 100.0 * 254) @@ -61,6 +38,7 @@ end local do_configure = function(self, device) device:send(device_management.build_bind_request(device, Level.ID, self.environment_info.hub_zigbee_eui)) device:send(Level.attributes.CurrentLevel:configure_reporting(device, 1, 3600, 1)) + device:refresh() end local feibit_handler = { @@ -86,7 +64,7 @@ local feibit_handler = { lifecycle_handlers = { doConfigure = do_configure, }, - can_handle = is_zigbee_window_shade, + can_handle = require("feibit.can_handle"), } return feibit_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/can_handle.lua new file mode 100644 index 0000000000..65f1a6dc75 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function hanssem_can_handle(opts, driver, device, ...) + if device:get_model() == "TS0601" then + return true, require("hanssem") + end + return false +end + +return hanssem_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua index be956d79b2..41b4eb1cac 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua @@ -1,3 +1,6 @@ +-- Copyright 2021-2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- -- Based on https://github.com/iquix/ST-Edge-Driver/blob/master/tuya-window-shade/src/init.lua -- Copyright 2021-2022 Jaewon Park (iquix) @@ -241,9 +244,7 @@ local hanssem_window_treatment = { added = device_added, infoChanged = device_info_changed }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "TS0601" - end + can_handle = require("hanssem.can_handle"), } -return hanssem_window_treatment \ No newline at end of file +return hanssem_window_treatment diff --git a/drivers/SmartThings/zigbee-window-treatment/src/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/init.lua index 3403b3d528..b58c20a5be 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local ZigbeeDriver = require "st.zigbee" @@ -43,33 +33,26 @@ local zigbee_window_treatment_driver_template = { capabilities.windowShade, capabilities.windowShadePreset, capabilities.windowShadeLevel, + capabilities.statelessWindowShadeLevelStep, capabilities.powerSource, capabilities.battery }, - sub_drivers = { - require("vimar"), - require("aqara"), - require("feibit"), - require("somfy"), - require("invert-lift-percentage"), - require("rooms-beautiful"), - require("axis"), - require("yoolax"), - require("hanssem"), - require("screen-innovations"), - require("VIVIDSTORM"), - require("HOPOsmart")}, - lifecycle_handlers = { - init = init_handler, - added = added_handler - }, capability_handlers = { [capabilities.windowShadePreset.ID] = { [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = window_shade_utils.set_preset_position_cmd, [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_utils.window_shade_preset_cmd, - } + }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = window_shade_utils.step_shade_level_handler + }, + }, + lifecycle_handlers = { + init = init_handler, + added = added_handler }, + sub_drivers = require("sub_drivers"), health_check = false, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(zigbee_window_treatment_driver_template, zigbee_window_treatment_driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua new file mode 100644 index 0000000000..69cfd4393a --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function invert_lift_percentage_can_handle(opts, driver, device, ...) + if device:get_manufacturer() == "IKEA of Sweden" or + device:get_manufacturer() == "Smartwings" or + device:get_manufacturer() == "Insta GmbH" + then + return true, require("invert-lift-percentage") + end + return false +end + +return invert_lift_percentage_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua index 19532bc847..b586459b9a 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -97,11 +87,7 @@ local ikea_window_treatment = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_cmd } }, - can_handle = function(opts, driver, device, ...) - return device:get_manufacturer() == "IKEA of Sweden" or - device:get_manufacturer() == "Smartwings" or - device:get_manufacturer() == "Insta GmbH" - end + can_handle = require("invert-lift-percentage.can_handle"), } return ikea_window_treatment diff --git a/drivers/SmartThings/zigbee-window-treatment/src/lazy_load_subdriver.lua b/drivers/SmartThings/zigbee-window-treatment/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..0bee6d2a75 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/lazy_load_subdriver.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(sub_driver_name) + -- gets the current lua libs api version + local version = require "version" + local ZigbeeDriver = require "st.zigbee" + if version.api >= 16 then + return ZigbeeDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZigbeeDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua new file mode 100644 index 0000000000..6bc25d2f91 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("rooms-beautiful.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("rooms-beautiful") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua new file mode 100644 index 0000000000..71ece32b8e --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Rooms Beautiful", model = "C001" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua index fc4883aa7f..bb868a8716 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -21,22 +11,11 @@ local PowerConfiguration = zcl_clusters.PowerConfiguration local OnOff = zcl_clusters.OnOff local WindowCovering = zcl_clusters.WindowCovering -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Rooms Beautiful", model = "C001" } -} local INVERT_CLUSTER = 0xFC00 local INVERT_CLUSTER_ATTRIBUTE = 0x0000 local PREV_TIME = "shadeLevelCmdTime" -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function invert_preference_handler(device) local window_level = device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) or 0 @@ -129,7 +108,7 @@ local rooms_beautiful_handler = { init = battery_defaults.build_linear_voltage_init(2.5, 3.0), infoChanged = info_changed }, - can_handle = is_zigbee_window_shade, + can_handle = require("rooms-beautiful.can_handle"), } return rooms_beautiful_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/can_handle.lua new file mode 100644 index 0000000000..df291c2612 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function screen_innovations_can_handle(opts, driver, device, ...) + if device:get_model() == "WM25/L-Z" then + return true, require("screen-innovations") + end + return false +end + +return screen_innovations_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/init.lua index 868e92af56..49397a5369 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/screen-innovations/init.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- require st provided libraries local capabilities = require "st.capabilities" @@ -173,9 +163,7 @@ local screeninnovations_roller_shade_handler = { } } }, - can_handle = function(opts, driver, device, ...) - return device:get_model() == "WM25/L-Z" - end + can_handle = require("screen-innovations.can_handle"), } -- return the handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua new file mode 100644 index 0000000000..e407ea7198 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("sombra.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sombra") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua new file mode 100644 index 0000000000..bac50c21bb --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Sombra Shades", model = "SOMBRA/Z-M" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua new file mode 100644 index 0000000000..634d7372e3 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sombra/init.lua @@ -0,0 +1,175 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- require st provided libraries +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local window_shade_utils = require "window_shade_utils" +local device_management = require "st.zigbee.device_management" +local utils = require "st.utils" + +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration + +-- Sombra motors report position in the shade-industry convention +-- (0% = open, 100% = closed), which is passed straight through to +-- windowShadeLevel.shadeLevel with no inversion. +-- +-- Opening/Closing status is inferred from the direction the position is +-- changing, so it works on every Sombra motor variant -- battery and DC -- +-- regardless of any manufacturer-specific motion cluster. When position +-- reports stop arriving, a short settle timer emits the resting state. +local SETTLE_DELAY = 3 +local SETTLE_TIMER = "shade_settle_timer" + +----------------------------------------------------------------- +-- local functions +----------------------------------------------------------------- + +-- emit the resting windowShade state for a level (0 = open, 100 = closed) +local function emit_resting_state(device, level) + if level <= 0 then + device:emit_event(capabilities.windowShade.windowShade.open()) + elseif level >= 100 then + device:emit_event(capabilities.windowShade.windowShade.closed()) + else + device:emit_event(capabilities.windowShade.windowShade.partially_open()) + end +end + +-- cancel any pending settle timer +local function cancel_settle_timer(device) + local timer = device:get_field(SETTLE_TIMER) + if timer then + device.thread:cancel_timer(timer) + device:set_field(SETTLE_TIMER, nil) + end +end + +-- (re)start the settle timer; when it fires without a newer position report +-- the shade has stopped moving, so emit the resting state +local function schedule_settle(device) + cancel_settle_timer(device) + local timer = device.thread:call_with_delay(SETTLE_DELAY, function() + device:set_field(SETTLE_TIMER, nil) + local level = device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + emit_resting_state(device, level) + end) + device:set_field(SETTLE_TIMER, timer) +end + +-- this is do_refresh +local do_refresh = function(self, device) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:read(device)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:read(device)) + device:send(Basic.attributes.PowerSource:read(device)) +end + +-- this is window_shade_level_cmd +local function window_shade_level_cmd(driver, device, command) + local go_to_level = command.args.shadeLevel + -- send levels without inverting as: 0% lift (open) to 100% lift (closed) + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, go_to_level)) +end + +-- this is window_shade_preset_cmd +local function window_shade_preset_cmd(driver, device, command) + local level = window_shade_utils.get_preset_level(device, command.component) + -- send levels without inverting as: 0% lift (open) to 100% lift (closed) + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) +end + +-- this is device_added +local function device_added(self, device) + device:emit_event(capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" }, {visibility = {displayed = false}})) + device.thread:call_with_delay(3, function(d) + do_refresh(self, device) + end) +end + +-- this is current_position_attr_handler +-- position is 0 = open, 100 = closed; direction is inferred from the change +local function current_position_attr_handler(driver, device, value, zb_rx) + local level = value.value + local previous = device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + local windowShade = capabilities.windowShade.windowShade + + device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) + + if level <= 0 then + cancel_settle_timer(device) + device:emit_event(windowShade.open()) + elseif level >= 100 then + cancel_settle_timer(device) + device:emit_event(windowShade.closed()) + else + if level < previous then + device:emit_event(windowShade.opening()) + elseif level > previous then + device:emit_event(windowShade.closing()) + end + schedule_settle(device) + end +end + +-- this is do_configure +local function do_configure(self, device) + -- configure elements + device:send(device_management.build_bind_request(device, WindowCovering.ID, self.environment_info.hub_zigbee_eui)) + device:send(WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(device, 1, 3600, 1)) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, self.environment_info.hub_zigbee_eui)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 1, 3600, 1)) + device:send(device_management.build_bind_request(device, Basic.ID, self.environment_info.hub_zigbee_eui)) + device:send(Basic.attributes.PowerSource:configure_reporting(device, 1, 3600)) + + -- read elements + device.thread:call_with_delay(3, function(d) + do_refresh(self, device) + end) +end + +-- this is battery_perc_attr_handler +local function battery_perc_attr_handler(driver, device, value, zb_rx) + local converted_value = value.value / 2 + converted_value = utils.round(converted_value) + device:emit_event_for_endpoint(zb_rx.address_header.src_endpoint.value, + capabilities.battery.battery(utils.clamp_value(converted_value, 0, 100))) +end + +-- create the handler object +local sombra_roller_shade_handler = { + NAME = "sombra_roller_shade_handler", + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + capability_handlers = { + [capabilities.windowShadeLevel.ID] = { + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd + }, + [capabilities.windowShadePreset.ID] = { + [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_cmd + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + } + }, + zigbee_handlers = { + attr = { + [WindowCovering.ID] = { + [WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = current_position_attr_handler, + }, + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_perc_attr_handler, + } + } + }, + can_handle = require("sombra.can_handle"), +} + +-- return the handler +return sombra_roller_shade_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua new file mode 100644 index 0000000000..27a6c83ca3 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("somfy.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("somfy") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua new file mode 100644 index 0000000000..ce6094564c --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "SOMFY", model = "Glydea Ultra Curtain" }, + { mfr = "SOMFY", model = "Sonesse 30 WF Roller" }, + { mfr = "SOMFY", model = "Sonesse 40 Roller" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua index ffc9541b64..da416ba9ea 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local utils = require "st.utils" @@ -20,23 +10,10 @@ local WindowCovering = zcl_clusters.WindowCovering local GLYDEA_MOVE_THRESHOLD = 3 -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "SOMFY", model = "Glydea Ultra Curtain" }, - { mfr = "SOMFY", model = "Sonesse 30 WF Roller" }, - { mfr = "SOMFY", model = "Sonesse 40 Roller" } -} local MOVE_LESS_THAN_THRESHOLD = "_sameLevelEvent" local FINAL_STATE_POLL_TIMER = "_finalStatePollTimer" -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function overwrite_existing_timer_if_needed(device, new_timer) local old_timer = device:get_field(FINAL_STATE_POLL_TIMER) @@ -132,7 +109,7 @@ local somfy_handler = { } } }, - can_handle = is_zigbee_window_shade, + can_handle = require("somfy.can_handle"), } return somfy_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua new file mode 100644 index 0000000000..3353c87fd4 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("vimar"), + lazy_load_if_possible("aqara"), + lazy_load_if_possible("feibit"), + lazy_load_if_possible("somfy"), + lazy_load_if_possible("sombra"), + lazy_load_if_possible("invert-lift-percentage"), + lazy_load_if_possible("rooms-beautiful"), + lazy_load_if_possible("axis"), + lazy_load_if_possible("yoolax"), + lazy_load_if_possible("hanssem"), + lazy_load_if_possible("screen-innovations"), + lazy_load_if_possible("VIVIDSTORM"), + lazy_load_if_possible("HOPOsmart"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_ikea.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_ikea.lua index c0e83e5ab8..c64116d7fa 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_ikea.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_ikea.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -64,7 +54,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -83,7 +76,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -102,7 +98,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -132,7 +131,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -182,7 +184,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -206,7 +211,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 30) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -222,7 +230,129 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) }) - end + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Cancel existing set-status timer when a new partial level report arrives", + function() + -- First attr: level 90 sets T1 + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + -- Second attr arrives before T1 fires: should cancel T1 and create T2 + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 15) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 85 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + -- T2 fires; T1 was cancelled so only partially_open from T2 + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Timer callback emits closed when shade reaches level 0", + function() + -- First attr starts partial movement and arms a 1-second status timer + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + -- Second attr reports fully closed (level=0); goes through elseif branch, T1 still pending + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + -- T1 fires; get_latest_state returns 0 so the callback emits closed() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Timer callback emits open when shade reaches level 100", + function() + -- First attr starts partial movement and arms a 1-second status timer + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + -- Second attr reports fully open (level=100); goes through elseif branch, T1 still pending + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + -- T1 fires; get_latest_state returns 100 so the callback emits open() + test.mock_time.advance_time(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_yoolax.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_yoolax.lua index d51f303378..f505e0d661 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_yoolax.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_battery_yoolax.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -94,7 +84,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(99)) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -129,7 +122,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -148,7 +144,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -166,7 +165,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -202,7 +204,10 @@ test.register_coroutine_test( mock_device.id, clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -235,7 +240,10 @@ test.register_coroutine_test( test.mock_time.advance_time(30) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -283,7 +291,10 @@ test.register_coroutine_test( clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -329,7 +340,124 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Default response emits opening when current level is higher than target", + function() + -- Establish a partially-closed shade state (zigbee value 90 → shadeLevel 10) + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 90) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(10)) + ) + test.wait_for_events() + -- Send open command: MOST_RECENT_SETLEVEL = 0 (level = 100 - 100 = 0) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + test.wait_for_events() + -- Default response: current_level_zigbee=90, most_recent=0 → 90 > 0 → opening() + test.socket.zigbee:__queue_receive({ + mock_device.id, + build_default_response_msg(WindowCovering.ID, WindowCovering.server.commands.GoToLiftPercentage.ID, Status.SUCCESS) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Attr handler emits partially_open when report matches most-recent set level", + function() + -- Send presetPosition; preset level = 50 so MOST_RECENT_SETLEVEL = 50 (100-50=50) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + }) + test.wait_for_events() + -- Attr report value=50 matches MOST_RECENT_SETLEVEL; shade stops at partial level + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + -- current_level was nil → partially_open from the nil-check branch + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + -- most_recent matches and value is partial → partially_open again (from the match branch) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Spontaneous level report towards open emits opening event", + function() + -- First attr establishes a partial shade level (value=10 → shadeLevel=90) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(90)) + ) + -- Second attr moves toward open (value=5 < current zigbee 10 → opening) + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 5) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(95)) + ) + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_only_HOPOsmart.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_only_HOPOsmart.lua old mode 100755 new mode 100644 index 1e4a8b3a2a..2653d47555 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_only_HOPOsmart.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_shade_only_HOPOsmart.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -53,7 +43,10 @@ test.register_coroutine_test( local read_0x0000_messge = cluster_base.read_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE) test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -74,6 +67,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, clusters.WindowCovering.server.commands.UpOrOpen(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -98,6 +94,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.DownOrClose(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -117,6 +116,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -132,7 +134,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -147,7 +152,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -162,7 +170,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -177,7 +188,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -192,7 +206,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua index 6380e5ce51..30bc6bfa5c 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -63,7 +53,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -113,7 +106,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -134,6 +130,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, clusters.WindowCovering.server.commands.UpOrOpen(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -158,6 +157,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.DownOrClose(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -177,6 +179,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -202,6 +207,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 33) } } + }, + { + min_api_version = 14 } ) @@ -262,6 +270,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 20) } }, + }, + { + min_api_version = 14 } ) @@ -285,7 +296,10 @@ test.register_coroutine_test( mock_device.id, clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -317,7 +331,265 @@ test.register_coroutine_test( clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - positive step", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 60) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - negative step", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 30) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 30 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 30) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - accumulated steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 60) + }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 55) + }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 15 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 70) + }) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 90) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 90) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) + }) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 10 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 10) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - zero stepSize", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } +) + +-- Test step_shade_level_handler with zero step +test.register_coroutine_test( + "step_shade_level_handler returns early when stepSize is zero", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + -- Send zero step - should return early without sending any command + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + test.wait_for_events() + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_VWSDSTUST120H.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_VWSDSTUST120H.lua old mode 100755 new mode 100644 index 394498b3c8..ba5b9c20a5 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_VWSDSTUST120H.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_VWSDSTUST120H.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -59,7 +49,10 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -75,7 +68,10 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({mock_device.id, read_0x0000_messge}) test.socket.zigbee:__expect_send({mock_device.id, read_0x0001_messge}) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -96,6 +92,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, clusters.WindowCovering.server.commands.UpOrOpen(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -120,6 +119,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.DownOrClose(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -139,6 +141,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 14 } ) @@ -153,7 +158,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 0) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -167,7 +175,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 1) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -181,7 +192,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 2) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -195,7 +209,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, 0x0000, MFG_CODE, data_types.Uint8, 3) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -210,7 +227,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.mode("Delete upper limit"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -225,7 +245,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.mode("Set the upper limit"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -240,7 +263,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.mode("Delete lower limit"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -255,7 +281,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.mode.mode("Set the lower limit"))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -270,7 +299,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("hardwareFault", capabilities.hardwareFault.hardwareFault.clear())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -285,7 +317,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("hardwareFault", capabilities.hardwareFault.hardwareFault.detected())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -303,7 +338,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -321,7 +359,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara.lua index d669681c0b..23b54edab5 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local base64 = require "st.base64" local capabilities = require "st.capabilities" @@ -151,7 +141,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, data_types.CharString, PREF_SOFT_TOUCH_ON) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -183,7 +176,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_read(mock_device, Basic.ID, { PREF_ATTRIBUTE_ID }, MFG_CODE) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -202,7 +198,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -221,7 +220,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -240,7 +242,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -256,7 +261,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device, 'open') }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -272,7 +280,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.DownOrClose(mock_device, 'close') }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -288,7 +299,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.Stop(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -332,7 +346,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", deviceInitialization.initializedState.initialized())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -361,7 +378,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, data_types.CharString, PREF_SOFT_TOUCH_OFF) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -382,7 +402,10 @@ test.register_coroutine_test( mock_device.id, AnalogOutput.attributes.PresentValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -409,7 +432,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_version_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -436,7 +462,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_version_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -463,7 +492,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_version_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -480,7 +512,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -507,7 +542,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -536,7 +574,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -566,7 +607,10 @@ test.register_coroutine_test( cluster_base.read_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -585,7 +629,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -604,7 +651,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) ) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_curtain_driver_e1.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_curtain_driver_e1.lua index 2b095c6c16..82d5fac234 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_curtain_driver_e1.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_curtain_driver_e1.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local zigbee_test_utils = require "integration_test.zigbee_test_utils" local cluster_base = require "st.zigbee.cluster_base" local clusters = require "st.zigbee.zcl.clusters" @@ -119,7 +109,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(100)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -163,7 +156,10 @@ test.register_coroutine_test( PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -177,7 +173,10 @@ test.register_coroutine_test( custom_write_attribute(mock_device , WindowCovering.ID, WindowCovering.attributes.Mode.ID, data_types.Bitmap8, 0x01, nil) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -189,7 +188,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_MANUAL_ATTRIBUTE_ID, MFG_CODE, data_types.Boolean, false) }) - end + end, + { + min_api_version = 14 + } ) @@ -217,7 +219,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -243,7 +248,10 @@ test.register_coroutine_test( -- mock_device.id, -- WindowCovering.server.commands.DownOrClose(mock_device) -- }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -270,7 +278,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.Stop(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -285,7 +296,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x01) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -300,7 +314,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER_ID, PRIVATE_CURTAIN_LOCKING_SETTING_ATTRIBUTE_ID, MFG_CODE, data_types.Uint8, 0x00) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -329,7 +346,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) @@ -346,7 +366,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", chargingState.chargingState.charging()) ) - end + end, + { + min_api_version = 14 + } ) @@ -363,6 +386,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -379,7 +405,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.initialized()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -395,7 +424,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -411,7 +443,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", hookLockState.hookLockState.locking()) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_roller_shade_rotate.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_roller_shade_rotate.lua index a27f85f528..4f8c0780c7 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_roller_shade_rotate.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_aqara_roller_shade_rotate.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local base64 = require "st.base64" local capabilities = require "st.capabilities" @@ -124,7 +114,10 @@ test.register_coroutine_test( cluster_base.write_manufacturer_specific_attribute(mock_device, Basic.ID, PREF_ATTRIBUTE_ID, MFG_CODE, data_types.CharString, PREF_REVERSE_OFF) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -156,7 +149,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_read(mock_device, Basic.ID, { PREF_ATTRIBUTE_ID }, MFG_CODE) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -175,7 +171,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -194,7 +193,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -213,7 +215,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -239,7 +244,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -265,7 +273,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -281,7 +292,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.Stop(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -297,7 +311,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -314,7 +331,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", shadeRotateState.rotateState.idle({state_change = true, visibility = { displayed = false }})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -349,7 +369,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.initialized())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -370,7 +393,10 @@ test.register_coroutine_test( mock_device.id, AnalogOutput.attributes.PresentValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -399,7 +425,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) }) - end + end, + { + min_api_version = 14 + } ) @@ -415,7 +444,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", initializedStateWithGuide.initializedStateWithGuide.notInitialized())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -432,7 +464,10 @@ test.register_coroutine_test( mock_device.id, AnalogOutput.attributes.PresentValue:read(mock_device) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -470,7 +505,10 @@ test.register_coroutine_test( message.body.zcl_header.frame_ctrl = FrameCtrl(0x10) test.socket.zigbee:__expect_send({ mock_device.id, message }) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua old mode 100755 new mode 100644 index 5068f889a5..333eca0f9e --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_axis.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local base64 = require "st.base64" @@ -90,7 +80,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -128,7 +121,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -178,7 +174,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -203,7 +202,10 @@ test.register_coroutine_test( Level.server.commands.MoveToLevelWithOnOff(mock_device) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -232,7 +234,10 @@ test.register_coroutine_test( WindowCovering.server.commands.DownOrClose(mock_device) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -257,7 +262,10 @@ test.register_coroutine_test( Level.server.commands.MoveToLevelWithOnOff(mock_device, 0xFE) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -286,7 +294,10 @@ test.register_coroutine_test( WindowCovering.server.commands.UpOrOpen(mock_device, 0x64) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -323,7 +334,10 @@ test.register_coroutine_test( mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -362,7 +376,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.Stop(mock_device) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -387,7 +404,10 @@ test.register_coroutine_test( Level.server.commands.MoveToLevelWithOnOff(mock_device, 84) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -419,7 +439,10 @@ test.register_coroutine_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 33) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -444,7 +467,10 @@ test.register_coroutine_test( Level.server.commands.MoveToLevelWithOnOff(mock_device, 127) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -478,7 +504,10 @@ test.register_coroutine_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 50) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -510,7 +539,10 @@ test.register_coroutine_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 50) } ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -552,7 +584,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.SWBuildID:read(mock_device) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -587,7 +622,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.SWBuildID:read(mock_device) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -637,7 +675,12 @@ test.register_coroutine_test( zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) }) - end + test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_feibit.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_feibit.lua index c65d38281a..01f2e98556 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_feibit.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_feibit.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -20,6 +10,7 @@ local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local Level = clusters.Level +local WindowCovering = clusters.WindowCovering local mock_device = test.mock_device.build_test_zigbee_device( { profile = t_utils.get_profile_definition("window-treatment-profile.yml"), @@ -75,7 +66,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -125,7 +119,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -146,6 +143,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, clusters.WindowCovering.server.commands.UpOrOpen(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -170,6 +170,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.DownOrClose(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -189,6 +192,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -214,6 +220,9 @@ test.register_message_test( Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(33/100 * 254)) } } + }, + { + min_api_version = 17 } ) @@ -233,7 +242,10 @@ test.register_coroutine_test( mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(50/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -251,7 +263,10 @@ test.register_coroutine_test( mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(50/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -270,7 +285,10 @@ test.register_coroutine_test( mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(50/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -297,7 +315,10 @@ test.register_coroutine_test( mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(1/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -324,7 +345,10 @@ test.register_coroutine_test( mock_device.id, Level.server.commands.MoveToLevelWithOnOff(mock_device,math.floor(0/100 * 254)) }) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -349,6 +373,9 @@ test.register_message_test( Level.server.commands.MoveToLevelWithOnOff(mock_device, math.floor(50 / 100 * 254)) } } + }, + { + min_api_version = 17 } ) @@ -372,7 +399,10 @@ test.register_coroutine_test( mock_device.id, Level.attributes.CurrentLevel:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -396,8 +426,12 @@ test.register_coroutine_test( zigbee_test_utils.mock_hub_eui, Level.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_hanssem.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_hanssem.lua index 2e511c99bb..15cae0e344 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_hanssem.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_hanssem.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zigbee_test_utils = require "integration_test.zigbee_test_utils" @@ -101,7 +91,10 @@ test.register_coroutine_test( mock_device.id, build_tx_message(mock_device,"\x02\x02\x00\x04\x00\x00\x00\x32") }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -143,7 +136,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -186,7 +182,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -241,7 +240,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -284,7 +286,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -336,7 +341,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(30))) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open())) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -368,7 +376,10 @@ test.register_coroutine_test( mock_device.id, build_tx_message(mock_device,"\x05\x04\x00\x01\x01") }) - end + end, + { + min_api_version = 17 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua new file mode 100644 index 0000000000..3c7c0cbee2 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua @@ -0,0 +1,346 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Test for statelessWindowShadeLevelStep capability with Somfy (reverse) devices +-- Tests devices like Somfy Glydea that use inverted lift percentage + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local WindowCovering = clusters.WindowCovering + +-- Create mock Somfy Glydea device using WindowCovering cluster with inverted logic +-- This device matches the somfy sub-driver which inverts the lift percentage +local somfy_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SOMFY", + model = "Glydea Ultra Curtain", + server_clusters = { WindowCovering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(somfy_device) + test.socket.capability:__expect_send( + somfy_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + somfy_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +-- Test 1: Boundary condition - stepSize = 0 should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - stepSize 0 does not send command", + function() + -- Set initial state via WindowCovering cluster report: 50% (inverted: device reports 50 for 50% shade) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 0 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + + -- Should NOT send any Zigbee command (stepSize 0 is ignored) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 2: Normal step up operation with Somfy (reverse) device +-- Somfy inverts the value: shadeLevel 60 means device goes to 100-60=40 +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - normal step up", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 10 + -- new_target_level = clamp(50 + 10, 0, 100) = 60 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 60 = 40 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 40) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Normal step down operation with Somfy (reverse) device +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - normal step down", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 25 (inverted: 100-25=75 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 25) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 75) + + -- Send stepShadeLevel command with stepSize = -25 + -- new_target_level = clamp(75 + (-25), 0, 100) = 50 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -25 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 50 = 50 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 50) + }) + end, + { min_api_version = 17 } +) + +-- Test 4: Boundary condition - value clamped to 100 (maximum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - value clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 10 (inverted: 100-10=90 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 10) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 90) + + -- Send stepShadeLevel command with stepSize = 50 + -- new_target_level = clamp(90 + 50, 0, 100) = 100 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 100 = 0 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 0) + }) + end, + { min_api_version = 17 } +) + +-- Test 5: Boundary condition - value clamped to 0 (minimum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - value clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 80 (inverted: 100-80=20 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 80) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 20 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 20) + + -- Send stepShadeLevel command with stepSize = -50 + -- new_target_level = clamp(20 + (-50), 0, 100) = 0 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 0 = 100 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 100) + }) + end, + { min_api_version = 17 } +) + +-- Test 6: Continuous step operations with mixed directions +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - continuous steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Step 1: stepSize = 10 (up) -> 60% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 60 = 40 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 40) + }) + test.wait_for_events() + + -- Step 2: stepSize = -5 (down) -> 55% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 55 = 45 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 45) + }) + test.wait_for_events() + + -- Step 3: stepSize = 20 (up) -> 75% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 75 = 25 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 25) + }) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 7: nil stepSize should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - nil stepSize does not send command", + function() + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with nil stepSize + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } + }) + + -- Should NOT send any Zigbee command + test.wait_for_events() + end, + { min_api_version = 17 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua new file mode 100644 index 0000000000..602aa34bd4 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua @@ -0,0 +1,400 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Test for statelessWindowShadeLevelStep capability with Level cluster devices +-- Tests devices like AXIS Gear that use Level cluster for window covering control + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local Level = clusters.Level +local WindowCovering = clusters.WindowCovering + +-- Create mock AXIS Gear device using Level cluster +-- This device matches the axis sub-driver which uses Level cluster +local axis_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "AXIS", + model = "Gear", + server_clusters = { Level.ID, WindowCovering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(axis_device) + test.socket.capability:__expect_send( + axis_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + axis_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +-- Test 1: Boundary condition - stepSize = 0 should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - stepSize 0 does not send command", + function() + -- Set initial state via Level cluster report: 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 0 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + + -- Should NOT send any Zigbee command (stepSize 0 is ignored) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 2: Normal step up operation with Level cluster +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - normal step up", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 10 + -- new_target_level = clamp(50 + 10, 0, 100) = 60 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 60 } } + }) + -- AXIS sub-driver also emits windowShade event (opening because 60 > 50) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + + -- Should send MoveToLevelWithOnOff with level 60 (152 out of 254, rounded from 152.4) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 152) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Normal step down operation with Level cluster +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - normal step down", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 75% (191 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 191) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 75) + + -- Send stepShadeLevel command with stepSize = -25 + -- new_target_level = clamp(75 + (-25), 0, 100) = 50 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -25 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- AXIS sub-driver also emits windowShade event (closing because 50 < 75) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + + -- Should send MoveToLevelWithOnOff with level 50 (127 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 127) + }) + end, + { min_api_version = 17 } +) + +-- Test 4: Boundary condition - value clamped to 100 (maximum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - value clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 90% (229 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 229) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 90) + + -- Send stepShadeLevel command with stepSize = 50 + -- new_target_level = clamp(90 + 50, 0, 100) = 100 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 100 } } + }) + -- AXIS sub-driver also emits windowShade event (opening because 100 > 90) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + + -- Should send MoveToLevelWithOnOff with level 100 (254 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 254) + }) + end, + { min_api_version = 17 } +) + +-- Test 5: Boundary condition - value clamped to 0 (minimum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - value clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 20% (51 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 51) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 20 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 20) + + -- Send stepShadeLevel command with stepSize = -50 + -- new_target_level = clamp(20 + (-50), 0, 100) = 0 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 0 } } + }) + -- AXIS sub-driver also emits windowShade event (closing because 0 < 20) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + + -- Should send MoveToLevelWithOnOff with level 0 (0 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 0) + }) + end, + { min_api_version = 17 } +) + +-- Test 6: Continuous step operations with mixed directions +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - continuous steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Step 1: stepSize = 10 (up) -> 60% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 60 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 152) + }) + test.wait_for_events() + + -- Step 2: stepSize = -5 (down) -> 55% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 55 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 140) + }) + test.wait_for_events() + + -- Step 3: stepSize = 20 (up) -> 75% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 191) + }) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 7: nil stepSize should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - nil stepSize does not send command", + function() + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with nil stepSize + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } + }) + + -- Should NOT send any Zigbee command + test.wait_for_events() + end, + { min_api_version = 17 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_rooms.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_rooms.lua index b3bc0b6c29..dfecbc28ab 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_rooms.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_rooms.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local base64 = require "st.base64" @@ -66,7 +56,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -85,7 +78,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -104,7 +100,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(25)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -123,7 +122,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -142,7 +144,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -162,7 +167,10 @@ test.register_coroutine_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device,100 - 33) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -179,7 +187,10 @@ test.register_coroutine_test( OnOff.server.commands.On(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -196,7 +207,10 @@ test.register_coroutine_test( OnOff.server.commands.Off(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -213,7 +227,10 @@ test.register_coroutine_test( WindowCovering.server.commands.Stop(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -232,7 +249,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc)) ) test.wait_for_events() end - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -274,7 +294,11 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, INVERT_CLUSTER, INVERT_CLUSTER_ATTRIBUTE, MFG_CODE, data_types.Boolean, updates.preferences.invert)}) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -309,7 +333,10 @@ test.register_coroutine_test( mock_device.id, zigbee_test_utils.build_attribute_read(mock_device, INVERT_CLUSTER, {INVERT_CLUSTER_ATTRIBUTE}, 0x0000) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -355,7 +382,10 @@ test.register_coroutine_test( zigbee_test_utils.build_attribute_read(mock_device, INVERT_CLUSTER, {INVERT_CLUSTER_ATTRIBUTE}, 0x0000) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_screen_innovations.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_screen_innovations.lua index b7f630cf71..17d8d61f76 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_screen_innovations.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_screen_innovations.lua @@ -1,16 +1,6 @@ --- Copyright 2024 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. +-- Copyright 2024 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -72,7 +62,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -91,7 +84,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -117,7 +113,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(25)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -134,7 +133,10 @@ test.register_coroutine_test( WindowCovering.server.commands.UpOrOpen(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -151,7 +153,10 @@ test.register_coroutine_test( WindowCovering.server.commands.DownOrClose(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -168,7 +173,10 @@ test.register_coroutine_test( WindowCovering.server.commands.Stop(mock_device) }) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -186,7 +194,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc_out)) ) test.wait_for_events() end - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -206,7 +217,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.PowerSource:read(mock_device) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -255,7 +269,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.PowerSource:read(mock_device) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -284,7 +301,10 @@ test.register_coroutine_test( mock_device.id, Basic.attributes.PowerSource:read(mock_device) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -324,7 +344,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(45)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -364,7 +387,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(85)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -404,7 +430,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) ) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -426,7 +455,10 @@ test.register_coroutine_test( Basic.attributes.PowerSource:build_test_attr_report(mock_device, 0)}) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown())) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua new file mode 100644 index 0000000000..febbfe6ea5 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_sombra.lua @@ -0,0 +1,434 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +-- Mock out globals +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local clusters = require "st.zigbee.zcl.clusters" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local Basic = clusters.Basic +local WindowCovering = clusters.WindowCovering +local PowerConfiguration = clusters.PowerConfiguration + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-powerSource.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Sombra Shades", + model = "SOMBRA/Z-M", + server_clusters = {0x0000, 0x0001, 0x0102} + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Window Shade state open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Window Shade state closed", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "A falling position reports opening, then settles to partially open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + -- establish a known starting position (fully closed) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.wait_for_events() + -- a lower position than before => opening + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 60) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(60)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.wait_for_events() + -- no further reports for the settle delay => partially open + test.mock_time.advance_time(3) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "A rising position reports closing, then settles to partially open", + function() + test.socket.capability:__set_channel_ordering("relaxed") + -- establish a known starting position (fully open) + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + ) + test.wait_for_events() + -- a higher position than before => closing + test.socket.zigbee:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 40) + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40)) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.wait_for_events() + -- no further reports for the settle delay => partially open + test.mock_time.advance_time(3) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "WindowShade open cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.UpOrOpen(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "WindowShade close cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "close", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.DownOrClose(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "WindowShade pause cmd test case", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShade", component = "main", command = "pause", args = {} } + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.server.commands.Stop(mock_device) + }) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Set shade level command", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 45 } } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 45) + } + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Preset position command", + function() + test.socket.capability:__queue_receive( + { + mock_device.id, + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } + } + ) + test.socket.zigbee:__expect_send( + { + mock_device.id, + WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + } + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Battery Percentage Remaining test cases", + function() + local battery_test_map = { + [200] = 100, + [100] = 50, + [0] = 0 + } + for bat_perc_rem, batt_perc_out in pairs(battery_test_map) do + test.socket.zigbee:__queue_receive({ mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, bat_perc_rem) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.battery.battery(batt_perc_out)) ) + test.wait_for_events() + end + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Power Source test cases", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 3) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery())) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 4) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.dc())) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ mock_device.id, + Basic.attributes.PowerSource:build_test_attr_report(mock_device, 0) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.powerSource.powerSource.unknown())) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Refresh should generate expected messages", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "doConfigure should generate expected messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, WindowCovering.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 1, 3600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Basic.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:configure_reporting(mock_device, 1, 3600) + }) + + -- read values after delay + test.mock_time.advance_time(3) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "added should generate expected messages", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added"}) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },{ visibility = { displayed = false }})) + ) + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.wait_for_events() + test.socket.zigbee:__set_channel_ordering("relaxed") + + -- read values after delay + test.mock_time.advance_time(3) + test.socket.zigbee:__expect_send({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Basic.attributes.PowerSource:read(mock_device) + }) + end, + { + min_api_version = 15 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_somfy.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_somfy.lua index 4c66d29257..7e89c956ea 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_somfy.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_somfy.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -76,7 +66,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -126,7 +119,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -163,7 +159,10 @@ test.register_coroutine_test( }) test.mock_time.advance_time(3) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -242,7 +241,10 @@ test.register_coroutine_test( } ) test.wait_for_events() - end + end, + { + min_api_version = 15 + } ) test.register_message_test( @@ -263,6 +265,9 @@ test.register_message_test( direction = "send", message = { mock_device.id, WindowCovering.server.commands.UpOrOpen(mock_device) } } + }, + { + min_api_version = 15 } ) @@ -287,6 +292,9 @@ test.register_message_test( WindowCovering.server.commands.DownOrClose(mock_device) } } + }, + { + min_api_version = 15 } ) @@ -306,6 +314,9 @@ test.register_message_test( WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 15 } ) @@ -331,6 +342,9 @@ test.register_message_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100 - 33) } } + }, + { + min_api_version = 15 } ) @@ -356,6 +370,9 @@ test.register_message_test( WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) } } + }, + { + min_api_version = 15 } ) @@ -381,7 +398,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 99) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -405,7 +425,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -423,7 +446,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -447,7 +473,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -469,7 +498,10 @@ test.register_coroutine_test( mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -502,7 +534,10 @@ test.register_coroutine_test( WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 15 + } ) test.register_coroutine_test( @@ -524,7 +559,111 @@ test.register_coroutine_test( } } ) - end + end, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Attribute handler reports closed when level is 0", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + }, + { + channel = "capability", + direction = "send", + message = { + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 0 } } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + } + }, + { + min_api_version = 15 + } +) + +test.register_message_test( + "Attribute handler reports open when level is 100", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + }, + { + channel = "capability", + direction = "send", + message = { + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 100 } } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + } + }, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "Cancel existing poll timer when a new partial level report arrives", + function() + -- First attr: level 90 creates T1 via overwrite_existing_timer_if_needed + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + -- Second attr before T1 fires: overwrite_existing_timer_if_needed cancels T1 and stores T2 + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 15) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 85 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + -- T2 fires; T1 was cancelled so only one partially_open + test.mock_time.advance_time(2) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_vimar.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_vimar.lua old mode 100755 new mode 100644 index 80a6552d8b..40e10d6b3b --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_vimar.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_vimar.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- Mock out globals local test = require "integration_test" @@ -71,7 +61,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -115,7 +108,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.windowShade.windowShade.partially_open()) ) test.wait_for_events() - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -149,6 +145,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) } }, + }, + { + min_api_version = 17 } ) @@ -178,6 +177,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) }, + }, + { + min_api_version = 17 } ) @@ -197,6 +199,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.Stop(mock_device) } } + }, + { + min_api_version = 17 } ) @@ -232,6 +237,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 67) } }, + }, + { + min_api_version = 17 } ) @@ -267,6 +275,9 @@ test.register_message_test( clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) } } + }, + { + min_api_version = 17 } ) @@ -290,7 +301,10 @@ test.register_coroutine_test( mock_device.id, clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -322,7 +336,140 @@ test.register_coroutine_test( clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:read(mock_device) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Attribute handler reports closed when shade reaches level 0", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 100) + } + }, + { + channel = "capability", + direction = "send", + message = { + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 0 } } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Attribute handler reports open when shade reaches level 100", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 0) + } + }, + { + channel = "capability", + direction = "send", + message = { + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 100 } } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShade.windowShade.open()) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SetLevel command emits closing when requested level is below current level", + function() + -- Attr report sets current shade level to 90 (inverted value=10) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.wait_for_events() + -- Now both vimar flags are false; requesting level 30 (< 90) triggers closing branch + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 30 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(30)) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 70) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "SetLevel command is ignored (early return) when shades are already moving", + function() + -- Open command: current=0 < 100 → opening, sets VIMAR_SHADES_OPENING=true + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShade", component = "main", command = "open", args = {} } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + test.wait_for_events() + -- While opening, a different setShadeLevel is requested: ignored with current level re-emitted + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 50 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua new file mode 100644 index 0000000000..1d72817eae --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local is_zigbee_window_shade = function(opts, driver, device) + local FINGERPRINTS = require("vimar.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("vimar") + end + end + return false +end + +return is_zigbee_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua new file mode 100644 index 0000000000..ea7f4cd3bf --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Vimar", model = "Window_Cov_v1.0" }, + { mfr = "Vimar", model = "Window_Cov_Module_v1.0" } +} + +return ZIGBEE_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua index 9fa928645a..dd5ea15aed 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local utils = require "st.utils" @@ -27,20 +17,8 @@ local windowShade = capabilities.windowShade.windowShade local VIMAR_SHADES_OPENING = "_vimarShadesOpening" local VIMAR_SHADES_CLOSING = "_vimarShadesClosing" -local ZIGBEE_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Vimar", model = "Window_Cov_v1.0" }, - { mfr = "Vimar", model = "Window_Cov_Module_v1.0" } -} -- UTILS to check manufacturer details -local is_zigbee_window_shade = function(opts, driver, device) - for _, fingerprint in ipairs(ZIGBEE_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end -- ATTRIBUTE HANDLER FOR CurrentPositionLiftPercentage local function current_position_attr_handler(driver, device, value, zb_rx) @@ -176,7 +154,7 @@ local vimar_handler = { lifecycle_handlers = { init = device_init }, - can_handle = is_zigbee_window_shade, + can_handle = require("vimar.can_handle"), } return vimar_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua index 262e549c2d..fd07648021 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua @@ -1,16 +1,7 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_utils = require "st.utils" local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -19,6 +10,15 @@ local utils = {} utils.PRESET_LEVEL = 50 utils.PRESET_LEVEL_KEY = "_presetLevel" +-- LATEST_TARGET_LEVEL stores the latest target position during stateless step operations. +-- It is cleared after TARGET_LEVEL_TIME_OUT_SECONDS timeout to allow accumulated steps +-- within a short time frame while preventing indefinite accumulation. +local LATEST_TARGET_LEVEL = "_latestTargetLevel" +-- Timeout in seconds for the stateless step accumulation window +local TARGET_LEVEL_TIME_OUT_SECONDS = 15 +-- Field name for the timer that clears LATEST_TARGET_LEVEL after timeout +local TARGET_LEVEL_TIME_OUT = "_targetLevelTimeOut" + utils.get_preset_level = function(device, component) local level = device:get_latest_state(component, "windowShadePreset", "position") or device:get_field(utils.PRESET_LEVEL_KEY) or @@ -41,4 +41,34 @@ utils.set_preset_position_cmd = function(driver, device, command) device:set_field(utils.PRESET_LEVEL_KEY, command.args.position, {persist = true}) end -return utils \ No newline at end of file +-- Step shade level handler for statelessWindowShadeLevelStep capability +utils.step_shade_level_handler = function(driver, device, command) + local step = command.args.stepSize or 0 + if step == 0 then return end + + -- Step from the latest target level if it exists, or from the current shade level + local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME, 0) + local new_target_level = st_utils.clamp_value(latest_target_level + step, 0, 100) + + -- Cancel any previous timer and set a 15 second timeout timer to ensure LATEST_TARGET_LEVEL is cleared + -- This is to prevent the stateless step from accumulating indefinitely, while ensuring that a single + -- "scroll" action can accumulate multiple steps within a short time frame + if device:get_field(TARGET_LEVEL_TIME_OUT) then + device.thread:cancel_timer(device:get_field(TARGET_LEVEL_TIME_OUT)) + end + local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) + device:set_field(LATEST_TARGET_LEVEL, nil) + end) + device:set_field(TARGET_LEVEL_TIME_OUT, timer) + device:set_field(LATEST_TARGET_LEVEL, new_target_level) + + driver:inject_capability_command(device, { + capability = capabilities.windowShadeLevel.ID, + component = command.component, + command = capabilities.windowShadeLevel.commands.setShadeLevel.NAME, + named_args = { shadeLevel = new_target_level } + }) +end + +return utils diff --git a/drivers/SmartThings/zigbee-window-treatment/src/window_treatment_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/window_treatment_utils.lua index 2f20ff2b4f..5b2ec304e6 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/window_treatment_utils.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/window_treatment_utils.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local window_treatment_utils = {} diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua new file mode 100644 index 0000000000..006fa3e1bb --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_yoolax_window_shade(opts, driver, device) + local FINGERPRINTS = require("yoolax.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("yoolax") + end + end + return false +end + +return is_yoolax_window_shade diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua new file mode 100644 index 0000000000..30e0dd4c62 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local YOOLAX_WINDOW_SHADE_FINGERPRINTS = { + { mfr = "Yookee", model = "D10110" }, -- Yookee Window Treatment + { mfr = "yooksmart", model = "D10110" } -- yooksmart Window Treatment +} + +return YOOLAX_WINDOW_SHADE_FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua index 46cb33fed2..5a593cdf2c 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -24,19 +14,7 @@ local device_management = require "st.zigbee.device_management" local LEVEL_UPDATE_TIMEOUT = "__level_update_timeout" local MOST_RECENT_SETLEVEL = "__most_recent_setlevel" -local YOOLAX_WINDOW_SHADE_FINGERPRINTS = { - { mfr = "Yookee", model = "D10110" }, -- Yookee Window Treatment - { mfr = "yooksmart", model = "D10110" } -- yooksmart Window Treatment -} -local function is_yoolax_window_shade(opts, driver, device) - for _, fingerprint in ipairs(YOOLAX_WINDOW_SHADE_FINGERPRINTS) do - if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true - end - end - return false -end local function default_response_handler(driver, device, zb_message) local is_success = zb_message.body.zcl_body.status.value @@ -160,7 +138,7 @@ local yoolax_window_shade = { } }, }, - can_handle = is_yoolax_window_shade + can_handle = require("yoolax.can_handle"), } return yoolax_window_shade diff --git a/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/can_handle.lua b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/can_handle.lua new file mode 100644 index 0000000000..6ff48d2fe2 --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeon_multiwhite_bulb(opts, driver, device, ...) + local FINGERPRINTS = require("aeon-multiwhite-bulb.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeon-multiwhite-bulb") + end + end + return false +end + +return can_handle_aeon_multiwhite_bulb diff --git a/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/fingerprints.lua b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/fingerprints.lua new file mode 100644 index 0000000000..6ec474efa9 --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEON_MULTIWHITE_BULB_FINGERPRINTS = { + {mfr = 0x0371, prod = 0x0103, model = 0x0001}, -- Aeon LED Bulb 6 Multi-White US + {mfr = 0x0371, prod = 0x0003, model = 0x0001}, -- Aeon LED Bulb 6 Multi-White EU + {mfr = 0x0300, prod = 0x0003, model = 0x0004} -- ilumin Tunable White +} + +return AEON_MULTIWHITE_BULB_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/init.lua b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/init.lua index 9907fefbb5..4a9cc277ca 100644 --- a/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/init.lua +++ b/drivers/SmartThings/zwave-bulb/src/aeon-multiwhite-bulb/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.utils @@ -26,25 +16,12 @@ local SwitchColor = (require "st.zwave.CommandClass.SwitchColor")({ version = 3 --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version = 4 }) -local AEON_MULTIWHITE_BULB_FINGERPRINTS = { - {mfr = 0x0371, prod = 0x0103, model = 0x0001}, -- Aeon LED Bulb 6 Multi-White US - {mfr = 0x0371, prod = 0x0003, model = 0x0001}, -- Aeon LED Bulb 6 Multi-White EU - {mfr = 0x0300, prod = 0x0003, model = 0x0004} -- ilumin Tunable White -} local WARM_WHITE_CONFIG = 0x51 local COLD_WHITE_CONFIG = 0x52 local SWITCH_COLOR_QUERY_DELAY = 2 local DEFAULT_COLOR_TEMPERATURE = 2700 -local function can_handle_aeon_multiwhite_bulb(opts, driver, device, ...) - for _, fingerprint in ipairs(AEON_MULTIWHITE_BULB_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function onoff_level_report_handler(self, device, cmd) local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value @@ -126,7 +103,7 @@ local aeon_multiwhite_bulb = { [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temperature } }, - can_handle = can_handle_aeon_multiwhite_bulb, + can_handle = require("aeon-multiwhite-bulb.can_handle"), lifecycle_handlers = { added = device_added } diff --git a/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/can_handle.lua b/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/can_handle.lua new file mode 100644 index 0000000000..ebeafc2c5c --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/can_handle.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is an Aeotec LED Bulb 6. +--- +--- @param driver Driver driver instance +--- @param device Device device isntance +--- @return boolean true if the device is an Aeotec LED Bulb 6, else false +local function is_aeotec_led_bulb_6(opts, driver, device, ...) + local AEOTEC_MFR_ID = 0x0371 + local AEOTEC_LED_BULB_6_PRODUCT_TYPE_US = 0x0103 + local AEOTEC_LED_BULB_6_PRODUCT_TYPE_EU = 0x0003 + local AEOTEC_LED_BULB_6_PRODUCT_ID = 0x0002 + if device:id_match( + AEOTEC_MFR_ID, + { AEOTEC_LED_BULB_6_PRODUCT_TYPE_US, AEOTEC_LED_BULB_6_PRODUCT_TYPE_EU }, + AEOTEC_LED_BULB_6_PRODUCT_ID) then + return true, require("aeotec-led-bulb-6") + end + return false +end + +return is_aeotec_led_bulb_6 diff --git a/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/init.lua b/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/init.lua index 67755878db..e4886c5897 100644 --- a/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/init.lua +++ b/drivers/SmartThings/zwave-bulb/src/aeotec-led-bulb-6/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.utils @@ -26,10 +16,6 @@ local SwitchColor = (require "st.zwave.CommandClass.SwitchColor")({ version=3 }) --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=4 }) -local AEOTEC_MFR_ID = 0x0371 -local AEOTEC_LED_BULB_6_PRODUCT_TYPE_US = 0x0103 -local AEOTEC_LED_BULB_6_PRODUCT_TYPE_EU = 0x0003 -local AEOTEC_LED_BULB_6_PRODUCT_ID = 0x0002 local WARM_WHITE_CONFIG = 0x51 local COLD_WHITE_CONFIG = 0x52 @@ -102,18 +88,6 @@ function capability_handlers.refresh(driver, device) device:send(Configuration:Get({ parameter_number=COLD_WHITE_CONFIG })) end ---- Determine whether the passed device is an Aeotec LED Bulb 6. ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device is an Aeotec LED Bulb 6, else false -local function is_aeotec_led_bulb_6(opts, driver, device, ...) - return device:id_match( - AEOTEC_MFR_ID, - { AEOTEC_LED_BULB_6_PRODUCT_TYPE_US, AEOTEC_LED_BULB_6_PRODUCT_TYPE_EU }, - AEOTEC_LED_BULB_6_PRODUCT_ID) -end - local aeotec_led_bulb_6 = { NAME = "Aeotec LED Bulb 6", zwave_handlers = { @@ -129,7 +103,7 @@ local aeotec_led_bulb_6 = { [capabilities.refresh.commands.refresh.NAME] = capability_handlers.refresh } }, - can_handle = is_aeotec_led_bulb_6, + can_handle = require("aeotec-led-bulb-6.can_handle"), } return aeotec_led_bulb_6 diff --git a/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/can_handle.lua b/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/can_handle.lua new file mode 100644 index 0000000000..aeef24b0ea --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_fibaro_rgbw_controller(opts, driver, device, ...) + local FIBARO_MFR_ID = 0x010F + local FIBARO_RGBW_CONTROLLER_PROD_TYPE = 0x0900 + local FIBARO_RGBW_CONTROLLER_PROD_ID_US = 0x2000 + local FIBARO_RGBW_CONTROLLER_PROD_ID_EU = 0x1000 + + if device:id_match( + FIBARO_MFR_ID, + FIBARO_RGBW_CONTROLLER_PROD_TYPE, + {FIBARO_RGBW_CONTROLLER_PROD_ID_US, FIBARO_RGBW_CONTROLLER_PROD_ID_EU} + ) then + return true, require("fibaro-rgbw-controller") + end + return false +end + +return is_fibaro_rgbw_controller diff --git a/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/init.lua b/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/init.lua index 80dc8d4dc7..b83be97c23 100644 --- a/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/init.lua +++ b/drivers/SmartThings/zwave-bulb/src/fibaro-rgbw-controller/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.Association @@ -33,19 +23,6 @@ local CAP_CACHE_KEY = "st.capabilities." .. capabilities.colorControl.ID local LAST_COLOR_SWITCH_CMD_FIELD = "lastColorSwitchCmd" local FAKE_RGB_ENDPOINT = 10 -local FIBARO_MFR_ID = 0x010F -local FIBARO_RGBW_CONTROLLER_PROD_TYPE = 0x0900 -local FIBARO_RGBW_CONTROLLER_PROD_ID_US = 0x2000 -local FIBARO_RGBW_CONTROLLER_PROD_ID_EU = 0x1000 - -local function is_fibaro_rgbw_controller(opts, driver, device, ...) - return device:id_match( - FIBARO_MFR_ID, - FIBARO_RGBW_CONTROLLER_PROD_TYPE, - {FIBARO_RGBW_CONTROLLER_PROD_ID_US, FIBARO_RGBW_CONTROLLER_PROD_ID_EU} - ) -end - -- This handler is copied from defaults with scraped of sets for both WHITE channels local function set_color(driver, device, command) local r, g, b = utils.hsl_to_rgb(command.args.color.hue, command.args.color.saturation, command.args.color.lightness) @@ -201,7 +178,7 @@ local fibaro_rgbw_controller = { added = device_added, init = device_init }, - can_handle = is_fibaro_rgbw_controller, + can_handle = require("fibaro-rgbw-controller.can_handle"), } return fibaro_rgbw_controller diff --git a/drivers/SmartThings/zwave-bulb/src/init.lua b/drivers/SmartThings/zwave-bulb/src/init.lua index 62079594fa..fd6c9a9b8f 100644 --- a/drivers/SmartThings/zwave-bulb/src/init.lua +++ b/drivers/SmartThings/zwave-bulb/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.Driver @@ -30,11 +20,8 @@ local driver_template = { capabilities.colorTemperature, capabilities.powerMeter }, - sub_drivers = { - require("aeotec-led-bulb-6"), - require("aeon-multiwhite-bulb"), - require("fibaro-rgbw-controller") - }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities, {native_capability_cmds_enabled = true}) diff --git a/drivers/SmartThings/zwave-bulb/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-bulb/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-bulb/src/sub_drivers.lua b/drivers/SmartThings/zwave-bulb/src/sub_drivers.lua new file mode 100644 index 0000000000..842e2056bd --- /dev/null +++ b/drivers/SmartThings/zwave-bulb/src/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aeotec-led-bulb-6"), + lazy_load_if_possible("aeon-multiwhite-bulb"), + lazy_load_if_possible("fibaro-rgbw-controller"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-bulb/src/test/test_aeon_multiwhite_bulb.lua b/drivers/SmartThings/zwave-bulb/src/test/test_aeon_multiwhite_bulb.lua index d6cdc044e0..fb2b6cf81c 100644 --- a/drivers/SmartThings/zwave-bulb/src/test/test_aeon_multiwhite_bulb.lua +++ b/drivers/SmartThings/zwave-bulb/src/test/test_aeon_multiwhite_bulb.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -70,7 +60,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = level})) } + }, + { + min_api_version = 14 } + ) end @@ -87,6 +81,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(2700)) } + }, + { + min_api_version = 14 } ) @@ -108,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 100})) } + }, + { + min_api_version = 14 } ) @@ -129,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 0})) } + }, + { + min_api_version = 14 } ) @@ -152,7 +155,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = level})) } + }, + { + min_api_version = 14 } + ) end @@ -174,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 100})) } + }, + { + min_api_version = 14 } ) @@ -195,6 +205,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 0})) } + }, + { + min_api_version = 14 } ) @@ -217,6 +230,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 0})) } + }, + { + min_api_version = 14 } ) @@ -239,6 +255,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 100})) } + }, + { + min_api_version = 14 } ) @@ -260,6 +279,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = 0})) } + }, + { + min_api_version = 14 } ) @@ -289,7 +311,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level({value = level})) } + }, + { + min_api_version = 14 } + ) end @@ -319,6 +345,9 @@ test.register_message_test( Configuration:Get({parameter_number = 0x52}) ) } + }, + { + min_api_version = 14 } ) @@ -348,6 +377,9 @@ test.register_message_test( Configuration:Get({parameter_number = 0x51}) ) } + }, + { + min_api_version = 14 } ) @@ -374,7 +406,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature({value = temp})) } + }, + { + min_api_version = 14 } + ) end @@ -400,7 +436,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -424,7 +463,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -449,7 +491,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -523,7 +568,10 @@ test.register_coroutine_test( SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.COLD_WHITE }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-bulb/src/test/test_aeotec_led_bulb_6.lua b/drivers/SmartThings/zwave-bulb/src/test/test_aeotec_led_bulb_6.lua index 0a1fa7e955..49ae2a0145 100644 --- a/drivers/SmartThings/zwave-bulb/src/test/test_aeotec_led_bulb_6.lua +++ b/drivers/SmartThings/zwave-bulb/src/test/test_aeotec_led_bulb_6.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -114,7 +104,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -166,7 +157,10 @@ test.register_coroutine_test( Configuration:Get({ parameter_number=parameter_number }) ) ) - end + end, + { + min_api_version = 14 + } ) do @@ -192,7 +186,11 @@ do direction = "send", message = mock_aeotec_bulb:generate_test_message("main", capabilities.colorTemperature.colorTemperature(temp)) } + }, + { + min_api_version = 14 } + ) end diff --git a/drivers/SmartThings/zwave-bulb/src/test/test_fibaro_rgbw_controller.lua b/drivers/SmartThings/zwave-bulb/src/test/test_fibaro_rgbw_controller.lua index 0fd3e98995..6d5782c676 100644 --- a/drivers/SmartThings/zwave-bulb/src/test/test_fibaro_rgbw_controller.lua +++ b/drivers/SmartThings/zwave-bulb/src/test/test_fibaro_rgbw_controller.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -108,7 +98,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -178,7 +169,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -208,7 +200,10 @@ test.register_coroutine_test( SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.WARM_WHITE }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -237,7 +232,10 @@ test.register_coroutine_test( SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.WARM_WHITE }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -267,6 +265,7 @@ test.register_message_test( } }, { + min_api_version = 14 } ) @@ -298,6 +297,7 @@ test.register_coroutine_test( ) end, { + min_api_version = 14 } ) @@ -319,6 +319,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -344,7 +347,11 @@ do direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("main", capabilities.switchLevel.level(level)) } + }, + { + min_api_version = 14 } + ) end @@ -389,7 +396,11 @@ do SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -465,7 +476,11 @@ do direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("rgb", capabilities.colorControl.saturation(sat)) } + }, + { + min_api_version = 14 } + ) end @@ -492,7 +507,11 @@ do direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("white", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } + ) end @@ -519,7 +538,11 @@ do direction = "send", message = mock_fibaro_rgbw_controller:generate_test_message("white", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } + ) end diff --git a/drivers/SmartThings/zwave-bulb/src/test/test_zwave_bulb.lua b/drivers/SmartThings/zwave-bulb/src/test/test_zwave_bulb.lua index cf704bec2d..eea8a679a2 100644 --- a/drivers/SmartThings/zwave-bulb/src/test/test_zwave_bulb.lua +++ b/drivers/SmartThings/zwave-bulb/src/test/test_zwave_bulb.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -109,7 +99,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -140,7 +131,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -167,6 +161,7 @@ test.register_message_test( } }, { + min_api_version = 14 } ) @@ -200,6 +195,7 @@ test.register_coroutine_test( ) end, { + min_api_version = 14 } ) @@ -232,6 +228,7 @@ test.register_coroutine_test( ) end, { + min_api_version = 14 } ) @@ -264,7 +261,11 @@ do direction = "send", message = mock_zwave_bulb:generate_test_message("main", capabilities.switchLevel.level(level)) } + }, + { + min_api_version = 14 } + ) end @@ -312,7 +313,11 @@ do SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -379,7 +384,11 @@ do direction = "send", message = mock_zwave_bulb:generate_test_message("main", capabilities.colorControl.saturation(sat)) } + }, + { + min_api_version = 14 } + ) end @@ -420,7 +429,10 @@ test.register_coroutine_test( SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.WARM_WHITE }) ) ) - end + end, + { + min_api_version = 14 + } ) do @@ -465,6 +477,9 @@ do direction = "send", message = mock_zwave_bulb:generate_test_message("main", capabilities.colorTemperature.colorTemperature(temp)) } + }, + { + min_api_version = 14 } ) end diff --git a/drivers/SmartThings/zwave-button/fingerprints.yml b/drivers/SmartThings/zwave-button/fingerprints.yml index 6a2995f5cc..df4a621220 100644 --- a/drivers/SmartThings/zwave-button/fingerprints.yml +++ b/drivers/SmartThings/zwave-button/fingerprints.yml @@ -78,4 +78,11 @@ zwaveManufacturer: manufacturerId: 0x0460 productType: 0x0009 productId: 0x0082 - deviceProfileName: multi-button-4-no-battery \ No newline at end of file + deviceProfileName: multi-button-4-no-battery + - id: "0312/0924/D001" + deviceLabel: Z-Wave Scene Control Switch + manufacturerId: 0x0312 + productId: 0xD001 + productType: 0x0924 + deviceProfileName: multi-button-4 + diff --git a/drivers/SmartThings/zwave-button/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-button/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-button/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-button/src/configurations.lua b/drivers/SmartThings/zwave-button/src/configurations.lua index 5dc1b96e0f..2c93b5075c 100644 --- a/drivers/SmartThings/zwave-button/src/configurations.lua +++ b/drivers/SmartThings/zwave-button/src/configurations.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { AEOTEC_NANOMOTE_ONE = { diff --git a/drivers/SmartThings/zwave-button/src/init.lua b/drivers/SmartThings/zwave-button/src/init.lua index b369197a5b..18469ca56b 100644 --- a/drivers/SmartThings/zwave-button/src/init.lua +++ b/drivers/SmartThings/zwave-button/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -41,10 +31,8 @@ local driver_template = { lifecycle_handlers = { added = added_handler, }, - sub_drivers = { - require("zwave-multi-button"), - require("apiv6_bugfix"), - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-button/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-button/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-button/src/sub_drivers.lua b/drivers/SmartThings/zwave-button/src/sub_drivers.lua new file mode 100644 index 0000000000..64a9813468 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-multi-button"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_minimote.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_minimote.lua index 3788947ef4..88bf890686 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_minimote.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_minimote.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -93,6 +86,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -120,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -146,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -172,6 +174,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -198,6 +203,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -224,6 +232,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -250,6 +261,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_minimote:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 17 } ) @@ -307,7 +321,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 140, size = 4, configuration_value = 26017792}) --payload="��" )) mock_aeotec_minimote:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -345,7 +362,10 @@ test.register_coroutine_test( ) end end - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_nanomote_one.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_nanomote_one.lua index 5aad3b2547..a7fd84e0e6 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_nanomote_one.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_aeotec_nanomote_one.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -66,7 +56,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_button.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_button.lua index 9cd4532bb1..155157e51b 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_button.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_button.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -57,6 +47,9 @@ test.register_message_test( direction = "send", message = mock:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -73,6 +66,9 @@ test.register_message_test( direction = "send", message = mock:generate_test_message("main", capabilities.button.button.held({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -91,6 +87,9 @@ test.register_message_test( direction = "send", message = mock:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -109,7 +108,11 @@ test.register_message_test( direction = "send", message = mock:generate_test_message("main", capabilities.button.button.down_hold({ state_change = true })) } + }, + { + min_api_version = 14 } + ) test.register_coroutine_test( @@ -136,7 +139,11 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 14 + } + ) @@ -164,6 +171,9 @@ test.register_coroutine_test( Battery:Get({}) ) }, + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_fibaro_button.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_fibaro_button.lua index a71e136998..39223ad82e 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_fibaro_button.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_fibaro_button.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -65,7 +55,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-button/src/test/test_zwave_multi_button.lua b/drivers/SmartThings/zwave-button/src/test/test_zwave_multi_button.lua index 27c9b851f9..5e1c27b371 100644 --- a/drivers/SmartThings/zwave-button/src/test/test_zwave_multi_button.lua +++ b/drivers/SmartThings/zwave-button/src/test/test_zwave_multi_button.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -116,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_everspring:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -142,6 +135,9 @@ test.register_message_test( direction = "send", message = mock_everspring:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -165,7 +161,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_everspring:generate_test_message( "main", capabilities.button.button.double({state_change = true}))) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -191,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_everspring:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -217,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_everspring:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -240,7 +245,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_everspring:generate_test_message( "main", capabilities.button.button.double({state_change = true}))) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -266,6 +274,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -292,6 +303,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -318,6 +332,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -340,7 +357,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_aeotec_wallmote_quad:generate_test_message( "main", capabilities.button.button.held({state_change = true}))) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -356,6 +376,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -383,6 +406,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_keyfob_button:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -409,6 +435,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_keyfob_button:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -435,6 +464,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_keyfob_button:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -461,6 +493,9 @@ test.register_message_test( direction = "send", message = mock_aeotec_keyfob_button:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -483,7 +518,10 @@ test.register_coroutine_test( Association:Set({grouping_identifier = 1, node_ids = {}}) )) mock_aeotec_keyfob_button:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -568,7 +606,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) --configuration for fibaro keyfob @@ -606,7 +647,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 26, size = 1, configuration_value = 15}) )) mock_fibaro_keyfob_button:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -719,7 +763,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -804,7 +851,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -861,7 +911,39 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Central scene notification with scene_number beyond profile buttons falls back to main component", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_aeotec_wallmote_quad.id, + zw_test_utils.zwave_test_build_receive_command( + CentralScene:Notification({ key_attributes = CentralScene.key_attributes.KEY_PRESSED_1_TIME, scene_number = 5 }) + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + }, + { + channel = "capability", + direction = "send", + message = mock_aeotec_wallmote_quad:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + } + }, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/can_handle.lua new file mode 100644 index 0000000000..5a2fec217c --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_keyfob(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.aeotec-keyfob.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button.aeotec-keyfob") + end + end + return false +end + +return can_handle_aeotec_keyfob diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/fingerprints.lua new file mode 100644 index 0000000000..dd6a9c8219 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_AEOTEC_KEYFOB_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0101, model = 0x0058}, -- Aeotec KeyFob US + {mfr = 0x0086, prod = 0x0001, model = 0x0058}, -- Aeotec KeyFob EU + {mfr = 0x0086, prod = 0x0001, model = 0x0026} -- Aeotec Panic Button +} + +return ZWAVE_AEOTEC_KEYFOB_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/init.lua index c8b655bff2..13096c0579 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-keyfob/init.lua @@ -1,37 +1,11 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) --- @type st.zwave.CommandClass.Association local Association = (require "st.zwave.CommandClass.Association")({ version=1 }) -local ZWAVE_AEOTEC_KEYFOB_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0101, model = 0x0058}, -- Aeotec KeyFob US - {mfr = 0x0086, prod = 0x0001, model = 0x0058}, -- Aeotec KeyFob EU - {mfr = 0x0086, prod = 0x0001, model = 0x0026} -- Aeotec Panic Button -} - -local function can_handle_aeotec_keyfob(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_AEOTEC_KEYFOB_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local do_configure = function(self, device) device:refresh() device:send(Configuration:Set({ configuration_value = 1, parameter_number = 250, size = 1 })) @@ -43,7 +17,8 @@ local aeotec_keyfob = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_aeotec_keyfob, + can_handle = require("zwave-multi-button.aeotec-keyfob.can_handle"), + shared_device_thread_enabled = true, } return aeotec_keyfob diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/can_handle.lua new file mode 100644 index 0000000000..fb39fa8f70 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_minimote(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.aeotec-minimote.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button.aeotec-minimote") + end + end + return false +end + +return can_handle_aeotec_minimote diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/fingerprints.lua new file mode 100644 index 0000000000..b63e217394 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_AEOTEC_MINIMOTE_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0001, model = 0x0003} -- Aeotec Mimimote +} + +return ZWAVE_AEOTEC_MINIMOTE_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/init.lua index 814bcb775b..3b8a04d90c 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/aeotec-minimote/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,18 +10,7 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) -local ZWAVE_AEOTEC_MINIMOTE_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0001, model = 0x0003} -- Aeotec Mimimote -} -local function can_handle_aeotec_minimote(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_AEOTEC_MINIMOTE_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function basic_set_handler(self, device, cmd) local button = cmd.args.value // 40 + 1 @@ -59,7 +38,8 @@ local aeotec_minimote = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_aeotec_minimote, + can_handle = require("zwave-multi-button.aeotec-minimote.can_handle"), + shared_device_thread_enabled = true, } return aeotec_minimote diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/can_handle.lua new file mode 100644 index 0000000000..a9d7d24be2 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zwave_multi_button(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button") + end + end + return false +end + +return can_handle_zwave_multi_button diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/can_handle.lua new file mode 100644 index 0000000000..055e4f1eff --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_keyfob(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.fibaro-keyfob.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button.fibaro-keyfob") + end + end + return false +end + +return can_handle_fibaro_keyfob diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/fingerprints.lua new file mode 100644 index 0000000000..269d136689 --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_FIBARO_KEYFOB_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1001, model = 0x1000}, -- Fibaro KeyFob EU + {mfr = 0x010F, prod = 0x1001, model = 0x2000}, -- Fibaro KeyFob US + {mfr = 0x010F, prod = 0x1001, model = 0x3000} -- Fibaro KeyFob AU +} + +return ZWAVE_FIBARO_KEYFOB_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/init.lua index 653cd21ddd..178d440783 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fibaro-keyfob/init.lua @@ -1,34 +1,11 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) -local ZWAVE_FIBARO_KEYFOB_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1001, model = 0x1000}, -- Fibaro KeyFob EU - {mfr = 0x010F, prod = 0x1001, model = 0x2000}, -- Fibaro KeyFob US - {mfr = 0x010F, prod = 0x1001, model = 0x3000} -- Fibaro KeyFob AU -} -local function can_handle_fibaro_keyfob(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_FIBARO_KEYFOB_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function do_configure(self, device) device:refresh() @@ -46,7 +23,8 @@ local fibaro_keyfob = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_fibaro_keyfob, + can_handle = require("zwave-multi-button.fibaro-keyfob.can_handle"), + shared_device_thread_enabled = true, } return fibaro_keyfob diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fingerprints.lua new file mode 100644 index 0000000000..4e539c90ab --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/fingerprints.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_MULTI_BUTTON_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1001, model = 0x1000}, -- Fibaro KeyFob EU + {mfr = 0x010F, prod = 0x1001, model = 0x2000}, -- Fibaro KeyFob US + {mfr = 0x010F, prod = 0x1001, model = 0x3000}, -- Fibaro KeyFob AU + {mfr = 0x0371, prod = 0x0002, model = 0x0003}, -- Aeotec NanoMote Quad EU + {mfr = 0x0371, prod = 0x0102, model = 0x0003}, -- Aeotec NanoMote Quad US + {mfr = 0x0086, prod = 0x0001, model = 0x0058}, -- Aeotec KeyFob EU + {mfr = 0x0086, prod = 0x0101, model = 0x0058}, -- Aeotec KeyFob US + {mfr = 0x0086, prod = 0x0002, model = 0x0082}, -- Aeotec Wallmote Quad EU + {mfr = 0x0086, prod = 0x0102, model = 0x0082}, -- Aeotec Wallmote Quad US + {mfr = 0x0086, prod = 0x0002, model = 0x0081}, -- Aeotec Wallmote EU + {mfr = 0x0086, prod = 0x0102, model = 0x0081}, -- Aeotec Wallmote US + {mfr = 0x0060, prod = 0x000A, model = 0x0003}, -- Everspring Remote Control + {mfr = 0x0086, prod = 0x0001, model = 0x0003}, -- Aeotec Mimimote, + {mfr = 0x0371, prod = 0x0102, model = 0x0016}, -- Aeotec illumino Wallmote 7, + {mfr = 0x0460, prod = 0x0009, model = 0x0081}, -- Shelly Wave i4, + {mfr = 0x0460, prod = 0x0009, model = 0x0082} -- Shelly Wave i4DC, +} + +return ZWAVE_MULTI_BUTTON_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/init.lua index 9094dc4111..57b9d42be4 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,33 +11,7 @@ local CentralScene = (require "st.zwave.CommandClass.CentralScene")({ version=1 --- @type st.zwave.CommandClass.SceneActivation local SceneActivation = (require "st.zwave.CommandClass.SceneActivation")({ version=1 }) -local ZWAVE_MULTI_BUTTON_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1001, model = 0x1000}, -- Fibaro KeyFob EU - {mfr = 0x010F, prod = 0x1001, model = 0x2000}, -- Fibaro KeyFob US - {mfr = 0x010F, prod = 0x1001, model = 0x3000}, -- Fibaro KeyFob AU - {mfr = 0x0371, prod = 0x0002, model = 0x0003}, -- Aeotec NanoMote Quad EU - {mfr = 0x0371, prod = 0x0102, model = 0x0003}, -- Aeotec NanoMote Quad US - {mfr = 0x0086, prod = 0x0001, model = 0x0058}, -- Aeotec KeyFob EU - {mfr = 0x0086, prod = 0x0101, model = 0x0058}, -- Aeotec KeyFob US - {mfr = 0x0086, prod = 0x0002, model = 0x0082}, -- Aeotec Wallmote Quad EU - {mfr = 0x0086, prod = 0x0102, model = 0x0082}, -- Aeotec Wallmote Quad US - {mfr = 0x0086, prod = 0x0002, model = 0x0081}, -- Aeotec Wallmote EU - {mfr = 0x0086, prod = 0x0102, model = 0x0081}, -- Aeotec Wallmote US - {mfr = 0x0060, prod = 0x000A, model = 0x0003}, -- Everspring Remote Control - {mfr = 0x0086, prod = 0x0001, model = 0x0003}, -- Aeotec Mimimote, - {mfr = 0x0371, prod = 0x0102, model = 0x0016}, -- Aeotec illumino Wallmote 7, - {mfr = 0x0460, prod = 0x0009, model = 0x0081}, -- Shelly Wave i4, - {mfr = 0x0460, prod = 0x0009, model = 0x0082} -- Shelly Wave i4DC, -} -local function can_handle_zwave_multi_button(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_MULTI_BUTTON_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local map_key_attribute_to_capability = { [CentralScene.key_attributes.KEY_PRESSED_1_TIME] = capabilities.button.button.pushed, @@ -115,12 +80,9 @@ local zwave_multi_button = { lifecycle_handlers = { init = device_init }, - can_handle = can_handle_zwave_multi_button, - sub_drivers = { - require("zwave-multi-button/aeotec-keyfob"), - require("zwave-multi-button/fibaro-keyfob"), - require("zwave-multi-button/aeotec-minimote") - } + can_handle = require("zwave-multi-button.can_handle"), + sub_drivers = require("zwave-multi-button.sub_drivers"), + shared_device_thread_enabled = true, } return zwave_multi_button diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/can_handle.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/can_handle.lua new file mode 100644 index 0000000000..6f532ab2af --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_shelly_wave_i4(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-multi-button.shelly_wave_i4.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-multi-button.shelly_wave_i4") + end + end + return false +end + +return can_handle_shelly_wave_i4 diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/fingerprints.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/fingerprints.lua new file mode 100644 index 0000000000..459a811d9a --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SHELLY_WAVE_i4_FINGERPRINTS = { + {mfr = 0x0460, prod = 0x0009, model = 0x0081}, -- Shelly Wave i4 + {mfr = 0x0460, prod = 0x0009, model = 0x0082} -- Shelly Wave i4 DC +} + +return SHELLY_WAVE_i4_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/init.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/init.lua index 4e962cebb1..4b78cffac1 100644 --- a/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/init.lua +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/shelly_wave_i4/init.lua @@ -1,35 +1,13 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) -- @type st.zwave.CommandClass.Association local Association = (require "st.zwave.CommandClass.Association")({ version=2 }) -local SHELLY_WAVE_i4_FINGERPRINTS = { - {mfr = 0x0460, prod = 0x0009, model = 0x0081}, -- Shelly Wave i4 - {mfr = 0x0460, prod = 0x0009, model = 0x0082} -- Shelly Wave i4 DC -} -local function can_handle_shelly_wave_i4(opts, driver, device, ...) - for _, fingerprint in ipairs(SHELLY_WAVE_i4_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local do_configure = function(self, device) device:refresh() @@ -45,7 +23,8 @@ local shelly_wave_i4 = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_shelly_wave_i4, + can_handle = require("zwave-multi-button.shelly_wave_i4.can_handle"), + shared_device_thread_enabled = true, } return shelly_wave_i4 diff --git a/drivers/SmartThings/zwave-button/src/zwave-multi-button/sub_drivers.lua b/drivers/SmartThings/zwave-button/src/zwave-multi-button/sub_drivers.lua new file mode 100644 index 0000000000..7ec5622dea --- /dev/null +++ b/drivers/SmartThings/zwave-button/src/zwave-multi-button/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-multi-button/aeotec-keyfob"), + lazy_load_if_possible("zwave-multi-button/fibaro-keyfob"), + lazy_load_if_possible("zwave-multi-button/aeotec-minimote"), + lazy_load_if_possible("zwave-multi-button/shelly_wave_i4"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-electric-meter/fingerprints.yml b/drivers/SmartThings/zwave-electric-meter/fingerprints.yml index 7c32d646e3..c580510daa 100644 --- a/drivers/SmartThings/zwave-electric-meter/fingerprints.yml +++ b/drivers/SmartThings/zwave-electric-meter/fingerprints.yml @@ -35,6 +35,22 @@ zwaveManufacturer: productType: 0x0002 productId: 0x0001 deviceProfileName: base-electric-meter + - id: "0x0371/0x0003/0x0033" #HEM Gen8 1 Phase EU, AU + deviceLabel: Aeotec Home Energy Meter Gen8 Consumption + manufacturerId: 0x0371 + productId: 0x0033 + deviceProfileName: aeotec-home-energy-meter-gen8-1-phase-con + - id: "0x0371/0x0003/0x0034" # HEM Gen8 3 Phase EU, AU + deviceLabel: Aeotec Home Energy Meter Gen8 Consumption + manufacturerId: 0x0371 + productId: 0x0034 + deviceProfileName: aeotec-home-energy-meter-gen8-3-phase-con + - id: "0x0371/0x0103/0x002E" # HEM Gen8 2 Phase US + deviceLabel: Aeotec Home Energy Meter Gen8 Consumption + manufacturerId: 0x0371 + productType: 0x0103 + productId: 0x002E + deviceProfileName: aeotec-home-energy-meter-gen8-2-phase-con zwaveGeneric: - id: "GenericEnergyMeter" deviceLabel: Energy Monitor diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-con.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-con.yml new file mode 100644 index 0000000000..b4de02cb6a --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-con.yml @@ -0,0 +1,107 @@ +name: aeotec-home-energy-meter-gen8-1-phase-con +components: +- id: main + label: "Sum Consumption" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp1 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - name: thresholdCheck + title: "3. Threshold Check Enable/Disable" + description: "Enable selective reporting only when power change reaches a certain threshold or percentage set in 4 -19 below. This is used to reduce network traffic." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 1 + - name: imWThresholdTotal + title: "4. Import W threshold (total)" + description: "Threshold change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWThresholdPhaseA + title: "5. Import W threshold (Phase A)" + description: "Threshold change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdTotal + title: "8. Export W threshold (total)" + description: "Threshold change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseA + title: "9. Export W threshold (Phase A)" + description: "Threshold change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWPctThresholdTotal + title: "12. Import W threshold (total)" + description: "Percentage change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseA + title: "13. Import W threshold (Phase A)" + description: "Percentage change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdTotal + title: "16. Export W threshold (total)" + description: "Percentage change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseA + title: "17. Export W threshold (Phase A)" + description: "Percentage change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: autoRootDeviceReport + title: "32. Auto report of root device" + description: "Enable automatic report of root device." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 0 diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-pro.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-pro.yml new file mode 100644 index 0000000000..9510e27037 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-1-phase-pro.yml @@ -0,0 +1,22 @@ +name: aeotec-home-energy-meter-gen8-1-phase-pro +components: +- id: main + label: "Sum Production" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp2 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-con.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-con.yml new file mode 100644 index 0000000000..64b2a510f9 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-con.yml @@ -0,0 +1,148 @@ +name: aeotec-home-energy-meter-gen8-2-phase-con +components: +- id: main + label: "Sum Consumption" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp1 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp3 + label: "Clamp 2" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - name: thresholdCheck + title: "3. Threshold Check Enable/Disable" + description: "Enable selective reporting only when power change reaches a certain threshold or percentage set in 4 -19 below. This is used to reduce network traffic." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 1 + - name: imWThresholdTotal + title: "4. Import W threshold (total)" + description: "Threshold change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWThresholdPhaseA + title: "5. Import W threshold (Phase A)" + description: "Threshold change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWhresholdPhaseB + title: "6. Import W threshold (Phase B)" + description: "Threshold change in import wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWhresholdTotal + title: "8. Export W threshold (total)" + description: "Threshold change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseA + title: "9. Export W threshold (Phase A)" + description: "Threshold change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseB + title: "10. Export W threshold (Phase B)" + description: "Threshold change in export wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWPctThresholdTotal + title: "12. Import W threshold (total)" + description: "Percentage change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseA + title: "13. Import W threshold (Phase A)" + description: "Percentage change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseB + title: "14. Import W threshold (Phase B)." + description: "Percentage change in import wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdTotal + title: "16. Export W threshold (total)" + description: "Percentage change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseA + title: "17. Export W threshold (Phase A)" + description: "Percentage change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseB + title: "18. Export W threshold (Phase B)" + description: "Percentage change in export wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: autoRootDeviceReport + title: "32. Auto report of root device" + description: "Enable automatic report of root device." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 0 diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-pro.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-pro.yml new file mode 100644 index 0000000000..be4adf21c5 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-2-phase-pro.yml @@ -0,0 +1,31 @@ +name: aeotec-home-energy-meter-gen8-2-phase-pro +components: +- id: main + label: "Sum Production" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp2 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp4 + label: "Clamp 2" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-con.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-con.yml new file mode 100644 index 0000000000..e1d44731bf --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-con.yml @@ -0,0 +1,189 @@ +name: aeotec-home-energy-meter-gen8-3-phase-con +components: +- id: main + label: "Sum Consumption" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: powerConsumptionReport + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp1 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp3 + label: "Clamp 2" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp5 + label: "Clamp 3" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +preferences: + - name: thresholdCheck + title: "3. Threshold Check Enable/Disable" + description: "Enable selective reporting only when power change reaches a certain threshold or percentage set in 4 -19 below. This is used to reduce network traffic." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 1 + - name: imWThresholdTotal + title: "4. Import W threshold (total)" + description: "Threshold change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWThresholdPhaseA + title: "5. Import W threshold (Phase A)" + description: "Threshold change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWhresholdPhaseB + title: "6. Import W threshold (Phase B)" + description: "Threshold change in import wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imtWThresholdPhaseC + title: "7. Import W threshold (Phase C)" + description: "Threshold change in import wattage to induce an automatic report (Phase C)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWhresholdTotal + title: "8. Export W threshold (total)" + description: "Threshold change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseA + title: "9. Export W threshold (Phase A)" + description: "Threshold change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseB + title: "10. Export W threshold (Phase B)" + description: "Threshold change in export wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: exWThresholdPhaseC + title: "11. Export W threshold (Phase C)" + description: "Threshold change in export wattage to induce an automatic report (Phase C)." + preferenceType: integer + definition: + minimum: 0 + maximum: 60000 + default: 50 + - name: imWPctThresholdTotal + title: "12. Import W threshold (total)" + description: "Percentage change in import wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseA + title: "13. Import W threshold (Phase A)" + description: "Percentage change in import wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseB + title: "14. Import W threshold (Phase B)" + description: "Percentage change in import wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: imWPctThresholdPhaseC + title: "15. Import W threshold (Phase C)" + description: "Percentage change in import wattage to induce an automatic report (Phase C)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdTotal + title: "16. Export W threshold (total)" + description: "Percentage change in export wattage to induce an automatic report (Whole HEM)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseA + title: "17. Export W threshold (Phase A)" + description: "Percentage change in export wattage to induce an automatic report (Phase A)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseB + title: "18. Export W threshold (Phase B)" + description: "Percentage change in export wattage to induce an automatic report (Phase B)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: exWPctThresholdPhaseC + title: "19. Export W threshold (Phase C)" + description: "Percentage change in export wattage to induce an automatic report (Phase C)." + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 20 + - name: autoRootDeviceReport + title: "32. Auto report of root device" + description: "Enable automatic report of root device." + preferenceType: enumeration + definition: + options: + 0: "Disable" + 1: "Enable" + default: 0 diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-pro.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-pro.yml new file mode 100644 index 0000000000..3efa762dda --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-3-phase-pro.yml @@ -0,0 +1,40 @@ +name: aeotec-home-energy-meter-gen8-3-phase-pro +components: +- id: main + label: "Sum Production" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp2 + label: "Clamp 1" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp4 + label: "Clamp 2" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor +- id: clamp6 + label: "Clamp 3" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-con.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-con.yml new file mode 100644 index 0000000000..f0bf405fb7 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-con.yml @@ -0,0 +1,13 @@ +name: aeotec-home-energy-meter-gen8-sald-con +components: +- id: main + label: "Settled Consumption" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-pro.yml b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-pro.yml new file mode 100644 index 0000000000..da05330d46 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/profiles/aeotec-home-energy-meter-gen8-sald-pro.yml @@ -0,0 +1,13 @@ +name: aeotec-home-energy-meter-gen8-sald-pro +components: +- id: main + label: "Settled Production" + capabilities: + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: PowerMeasurementSensor \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/can_handle.lua new file mode 100644 index 0000000000..a0bcfde376 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeon_meter(opts, driver, device, ...) + local FINGERPRINTS = require("aeon-meter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeon-meter") + end + end + return false +end + +return can_handle_aeon_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/fingerprints.lua b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/fingerprints.lua new file mode 100644 index 0000000000..5970f40d6d --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEON_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0002, model = 0x0009}, -- DSB09xxx-ZWUS + {mfr = 0x0086, prod = 0x0002, model = 0x0001}, -- DSB28-ZWEU +} + +return AEON_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/init.lua index 9f5ec4bee4..334bbb71cd 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/init.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/aeon-meter/init.lua @@ -1,34 +1,9 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) -local AEON_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0002, model = 0x0009}, -- DSB09xxx-ZWUS - {mfr = 0x0086, prod = 0x0002, model = 0x0001}, -- DSB28-ZWEU -} - -local function can_handle_aeon_meter(opts, driver, device, ...) - for _, fingerprint in ipairs(AEON_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local do_configure = function (self, device) device:send(Configuration:Set({parameter_number = 101, size = 4, configuration_value = 4})) -- combined power in watts... device:send(Configuration:Set({parameter_number = 111, size = 4, configuration_value = 300})) -- ...every 5 min @@ -42,7 +17,7 @@ local aeon_meter = { doConfigure = do_configure }, NAME = "aeon meter", - can_handle = can_handle_aeon_meter + can_handle = require("aeon-meter.can_handle"), } return aeon_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/can_handle.lua new file mode 100644 index 0000000000..356dbd55df --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_gen5_meter(opts, driver, device, ...) + local FINGERPRINTS = require("aeotec-gen5-meter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-gen5-meter") + end + end + return false +end + +return can_handle_aeotec_gen5_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/fingerprints.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/fingerprints.lua new file mode 100644 index 0000000000..3863c27b51 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_GEN5_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0102, model = 0x005F}, -- Aeotec Home Energy Meter (Gen5) US + {mfr = 0x0086, prod = 0x0002, model = 0x005F}, -- Aeotec Home Energy Meter (Gen5) EU +} + +return AEOTEC_GEN5_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/init.lua index 870e104891..1633427372 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/init.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-gen5-meter/init.lua @@ -1,34 +1,9 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) -local AEOTEC_GEN5_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0102, model = 0x005F}, -- Aeotec Home Energy Meter (Gen5) US - {mfr = 0x0086, prod = 0x0002, model = 0x005F}, -- Aeotec Home Energy Meter (Gen5) EU -} - -local function can_handle_aeotec_gen5_meter(opts, driver, device, ...) - for _, fingerprint in ipairs(AEOTEC_GEN5_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end - local do_configure = function (self, device) device:send(Configuration:Set({parameter_number = 101, size = 4, configuration_value = 3})) -- report total power in Watts and total energy in kWh... device:send(Configuration:Set({parameter_number = 102, size = 4, configuration_value = 0})) -- disable group 2... @@ -43,7 +18,7 @@ local aeotec_gen5_meter = { doConfigure = do_configure }, NAME = "aeotec gen5 meter", - can_handle = can_handle_aeotec_gen5_meter + can_handle = require("aeotec-gen5-meter.can_handle"), } return aeotec_gen5_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/can_handle.lua new file mode 100644 index 0000000000..6dd02b40fe --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS = { + { mfr = 0x0371, prod = 0x0003, model = 0x0033 }, -- HEM Gen8 1 Phase EU + { mfr = 0x0371, prod = 0x0102, model = 0x002E } -- HEM Gen8 1 Phase AU +} + +local function can_handle_aeotec_meter_gen8_1_phase(opts, driver, device, ...) + for _, fingerprint in ipairs(AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-home-energy-meter-gen8.1-phase") + end + end + return false +end + +return can_handle_aeotec_meter_gen8_1_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/init.lua new file mode 100644 index 0000000000..db69a9d52d --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/1-phase/init.lua @@ -0,0 +1,123 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_device = require "st.device" +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version=4 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local utils = require "st.utils" +local power_consumption = require("aeotec-home-energy-meter-gen8.power_consumption") + +local POWER_UNIT_WATT = "W" +local ENERGY_UNIT_KWH = "kWh" + +local HEM8_DEVICES = { + { profile = 'aeotec-home-energy-meter-gen8-1-phase-con', name = 'Aeotec Home Energy Meter 8 Consumption', endpoints = { 1, 3 } }, + { profile = 'aeotec-home-energy-meter-gen8-1-phase-pro', name = 'Aeotec Home Energy Meter 8 Production', child_key = 'pro', endpoints = { 2, 4 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-con', name = 'Aeotec Home Energy Meter 8 Settled Consumption', child_key = 'sald-con', endpoints = { 5 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-pro', name = 'Aeotec Home Energy Meter 8 Settled Production', child_key = 'sald-pro', endpoints = { 6 } } +} + +local function find_hem8_child_device_key_by_endpoint(endpoint) + for _, child in ipairs(HEM8_DEVICES) do + if child.endpoints then + for _, e in ipairs(child.endpoints) do + if e == endpoint then + return child.child_key + end + end + end + end +end + +local function meter_report_handler(driver, device, cmd, zb_rx) + local endpoint = cmd.src_channel + local device_to_emit_with = device + local child_device_key = find_hem8_child_device_key_by_endpoint(endpoint); + local child_device = device:get_child_by_parent_assigned_key(child_device_key) + + if(child_device) then + device_to_emit_with = child_device + end + + if cmd.args.scale == Meter.scale.electric_meter.KILOWATT_HOURS then + local event_arguments = { + value = cmd.args.meter_value, + unit = ENERGY_UNIT_KWH + } + -- energyMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.energyMeter.energy(event_arguments) + ) + + if endpoint == 5 then + -- powerConsumptionReport + power_consumption.emit_power_consumption_report_event(device, { value = event_arguments.value }) + end + elseif cmd.args.scale == Meter.scale.electric_meter.WATTS then + local event_arguments = { + value = cmd.args.meter_value, + unit = POWER_UNIT_WATT + } + -- powerMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.powerMeter.power(event_arguments) + ) + end +end + +local function do_refresh(self, device) + for _, d in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(d.endpoints) do + device:send(Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, {dst_channels = {endpoint}})) + device:send(Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {endpoint}})) + end + end +end + +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZWAVE and not (device.child_ids and utils.table_size(device.child_ids) ~= 0) then + for i, hem8_child in ipairs(HEM8_DEVICES) do + if(hem8_child["child_key"]) then + local name = hem8_child.name + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = hem8_child.profile, + parent_device_id = device.id, + parent_assigned_child_key = hem8_child.child_key, + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + do_refresh(driver, device) +end + +local aeotec_home_energy_meter_gen8_1_phase = { + NAME = "Aeotec Home Energy Meter Gen8", + supported_capabilities = { + capabilities.powerConsumptionReport + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zwave_handlers = { + [cc.METER] = { + [Meter.REPORT] = meter_report_handler + } + }, + lifecycle_handlers = { + added = device_added + }, + can_handle = require("aeotec-home-energy-meter-gen8.1-phase.can_handle") +} + +return aeotec_home_energy_meter_gen8_1_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/can_handle.lua new file mode 100644 index 0000000000..ec694e2708 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS = { + { mfr = 0x0371, prod = 0x0103, model = 0x002E } -- HEM Gen8 2 Phase US +} + +local function can_handle_aeotec_meter_gen8_2_phase(opts, driver, device, ...) + for _, fingerprint in ipairs(AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-home-energy-meter-gen8.2-phase") + end + end + return false +end + +return can_handle_aeotec_meter_gen8_2_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/init.lua new file mode 100644 index 0000000000..8d78da66f7 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/2-phase/init.lua @@ -0,0 +1,123 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_device = require "st.device" +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version=4 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local utils = require "st.utils" +local power_consumption = require("aeotec-home-energy-meter-gen8.power_consumption") + +local POWER_UNIT_WATT = "W" +local ENERGY_UNIT_KWH = "kWh" + +local HEM8_DEVICES = { + { profile = 'aeotec-home-energy-meter-gen8-3-phase-con', name = 'Aeotec Home Energy Meter 8 Consumption', endpoints = { 1, 3, 5 } }, + { profile = 'aeotec-home-energy-meter-gen8-2-phase-pro', name = 'Aeotec Home Energy Meter 8 Production', child_key = 'pro', endpoints = { 2, 4, 6 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-con', name = 'Aeotec Home Energy Meter 8 Settled Consumption', child_key = 'sald-con', endpoints = { 7 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-pro', name = 'Aeotec Home Energy Meter 8 Settled Production', child_key = 'sald-pro', endpoints = { 8 } } +} + +local function find_hem8_child_device_key_by_endpoint(endpoint) + for _, child in ipairs(HEM8_DEVICES) do + if child.endpoints then + for _, e in ipairs(child.endpoints) do + if e == endpoint then + return child.child_key + end + end + end + end +end + +local function meter_report_handler(driver, device, cmd, zb_rx) + local endpoint = cmd.src_channel + local device_to_emit_with = device + local child_device_key = find_hem8_child_device_key_by_endpoint(endpoint); + local child_device = device:get_child_by_parent_assigned_key(child_device_key) + + if(child_device) then + device_to_emit_with = child_device + end + + if cmd.args.scale == Meter.scale.electric_meter.KILOWATT_HOURS then + local event_arguments = { + value = cmd.args.meter_value, + unit = ENERGY_UNIT_KWH + } + -- energyMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.energyMeter.energy(event_arguments) + ) + + if endpoint == 7 then + -- powerConsumptionReport + power_consumption.emit_power_consumption_report_event(device, { value = event_arguments.value }) + end + elseif cmd.args.scale == Meter.scale.electric_meter.WATTS then + local event_arguments = { + value = cmd.args.meter_value, + unit = POWER_UNIT_WATT + } + -- powerMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.powerMeter.power(event_arguments) + ) + end +end + +local function do_refresh(self, device) + for _, d in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(d.endpoints) do + device:send(Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, {dst_channels = {endpoint}})) + device:send(Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {endpoint}})) + end + end +end + +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZWAVE and not (device.child_ids and utils.table_size(device.child_ids) ~= 0) then + for i, hem8_child in ipairs(HEM8_DEVICES) do + if(hem8_child["child_key"]) then + local name = hem8_child.name + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = hem8_child.profile, + parent_device_id = device.id, + parent_assigned_child_key = hem8_child.child_key, + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + do_refresh(driver, device) +end + +local aeotec_home_energy_meter_gen8_2_phase = { + NAME = "Aeotec Home Energy Meter Gen8", + supported_capabilities = { + capabilities.powerConsumptionReport + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zwave_handlers = { + [cc.METER] = { + [Meter.REPORT] = meter_report_handler + } + }, + lifecycle_handlers = { + added = device_added + }, + can_handle = require("aeotec-home-energy-meter-gen8.2-phase.can_handle") +} + +return aeotec_home_energy_meter_gen8_2_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/can_handle.lua new file mode 100644 index 0000000000..9f3cc6cb03 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/can_handle.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS = { + { mfr = 0x0371, prod = 0x0003, model = 0x0034 }, -- HEM Gen8 3 Phase EU + { mfr = 0x0371, prod = 0x0102, model = 0x0034 } -- HEM Gen8 3 Phase AU +} + +local function can_handle_aeotec_meter_gen8_3_phase(opts, driver, device, ...) + for _, fingerprint in ipairs(AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-home-energy-meter-gen8.3-phase") + end + end + return false +end + +return can_handle_aeotec_meter_gen8_3_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/init.lua new file mode 100644 index 0000000000..cb1c582fb6 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/3-phase/init.lua @@ -0,0 +1,123 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_device = require "st.device" +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version=4 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local utils = require "st.utils" +local power_consumption = require("aeotec-home-energy-meter-gen8.power_consumption") + +local POWER_UNIT_WATT = "W" +local ENERGY_UNIT_KWH = "kWh" + +local HEM8_DEVICES = { + { profile = 'aeotec-home-energy-meter-gen8-3-phase-con', name = 'Aeotec Home Energy Meter 8 Consumption', endpoints = { 1, 3, 5, 7 } }, + { profile = 'aeotec-home-energy-meter-gen8-3-phase-pro', name = 'Aeotec Home Energy Meter 8 Production', child_key = 'pro', endpoints = { 2, 4, 6, 8 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-con', name = 'Aeotec Home Energy Meter 8 Settled Consumption', child_key = 'sald-con', endpoints = { 9 } }, + { profile = 'aeotec-home-energy-meter-gen8-sald-pro', name = 'Aeotec Home Energy Meter 8 Settled Production', child_key = 'sald-pro', endpoints = { 10 } } +} + +local function find_hem8_child_device_key_by_endpoint(endpoint) + for _, child in ipairs(HEM8_DEVICES) do + if child.endpoints then + for _, e in ipairs(child.endpoints) do + if e == endpoint then + return child.child_key + end + end + end + end +end + +local function meter_report_handler(driver, device, cmd, zb_rx) + local endpoint = cmd.src_channel + local device_to_emit_with = device + local child_device_key = find_hem8_child_device_key_by_endpoint(endpoint); + local child_device = device:get_child_by_parent_assigned_key(child_device_key) + + if(child_device) then + device_to_emit_with = child_device + end + + if cmd.args.scale == Meter.scale.electric_meter.KILOWATT_HOURS then + local event_arguments = { + value = cmd.args.meter_value, + unit = ENERGY_UNIT_KWH + } + -- energyMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.energyMeter.energy(event_arguments) + ) + + if endpoint == 9 then + -- powerConsumptionReport + power_consumption.emit_power_consumption_report_event(device, { value = event_arguments.value }) + end + elseif cmd.args.scale == Meter.scale.electric_meter.WATTS then + local event_arguments = { + value = cmd.args.meter_value, + unit = POWER_UNIT_WATT + } + -- powerMeter + device_to_emit_with:emit_event_for_endpoint( + cmd.src_channel, + capabilities.powerMeter.power(event_arguments) + ) + end +end + +local function do_refresh(self, device) + for _, d in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(d.endpoints) do + device:send(Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, {dst_channels = {endpoint}})) + device:send(Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {endpoint}})) + end + end +end + +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZWAVE and not (device.child_ids and utils.table_size(device.child_ids) ~= 0) then + for i, hem8_child in ipairs(HEM8_DEVICES) do + if(hem8_child["child_key"]) then + local name = hem8_child.name + local metadata = { + type = "EDGE_CHILD", + label = name, + profile = hem8_child.profile, + parent_device_id = device.id, + parent_assigned_child_key = hem8_child.child_key, + vendor_provided_label = name + } + driver:try_create_device(metadata) + end + end + end + do_refresh(driver, device) +end + +local aeotec_home_energy_meter_gen8_3_phase = { + NAME = "Aeotec Home Energy Meter Gen8", + supported_capabilities = { + capabilities.powerConsumptionReport + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zwave_handlers = { + [cc.METER] = { + [Meter.REPORT] = meter_report_handler + } + }, + lifecycle_handlers = { + added = device_added + }, + can_handle = require("aeotec-home-energy-meter-gen8.3-phase.can_handle") +} + +return aeotec_home_energy_meter_gen8_3_phase diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/can_handle.lua new file mode 100644 index 0000000000..918e703b69 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/can_handle.lua @@ -0,0 +1,22 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS = { + { mfr = 0x0371, prod = 0x0003, model = 0x0033 }, -- HEM Gen8 1 Phase EU + { mfr = 0x0371, prod = 0x0003, model = 0x0034 }, -- HEM Gen8 3 Phase EU + { mfr = 0x0371, prod = 0x0103, model = 0x002E }, -- HEM Gen8 2 Phase US + { mfr = 0x0371, prod = 0x0102, model = 0x002E }, -- HEM Gen8 1 Phase AU + { mfr = 0x0371, prod = 0x0102, model = 0x0034 }, -- HEM Gen8 3 Phase AU +} + +local function can_handle_aeotec_meter_gen8(opts, driver, device, ...) + for _, fingerprint in ipairs(AEOTEC_HOME_ENERGY_METER_GEN8_FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("aeotec-home-energy-meter-gen8") + return true, subdriver + end + end + return false +end + +return can_handle_aeotec_meter_gen8 diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/init.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/init.lua new file mode 100644 index 0000000000..10d07de685 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/init.lua @@ -0,0 +1,49 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- @type st.zwave.CommandClass.Configuration +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) + +local function device_added(driver, device) + device:refresh() +end + +local function component_to_endpoint(device, component_id) + local ep_num = component_id:match("clamp(%d)") + return { ep_num and tonumber(ep_num) } +end + +local function endpoint_to_component(device, ep) + local meter_comp = string.format("clamp%d", ep) + if device.profile.components[meter_comp] ~= nil then + return meter_comp + else + return "main" + end +end + +local device_init = function(self, device) + device:set_component_to_endpoint_fn(component_to_endpoint) + device:set_endpoint_to_component_fn(endpoint_to_component) +end + +local do_configure = function (self, device) + device:send(Configuration:Set({parameter_number = 111, configuration_value = 300, size = 4})) -- ...every 5 min + device:send(Configuration:Set({parameter_number = 112, configuration_value = 300, size = 4})) -- ...every 5 min + device:send(Configuration:Set({parameter_number = 113, configuration_value = 300, size = 4})) -- ...every 5 min +end + +local aeotec_home_energy_meter_gen8 = { + NAME = "Aeotec Home Energy Meter Gen8", + lifecycle_handlers = { + added = device_added, + init = device_init, + doConfigure = do_configure + }, + can_handle = require("aeotec-home-energy-meter-gen8.can_handle"), + sub_drivers = { + require("aeotec-home-energy-meter-gen8.sub_drivers") + } +} + +return aeotec_home_energy_meter_gen8 diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/power_consumption.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/power_consumption.lua new file mode 100644 index 0000000000..2c32c5c964 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/power_consumption.lua @@ -0,0 +1,32 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2. + +local capabilities = require "st.capabilities" + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local power_consumption = {} + +power_consumption.emit_power_consumption_report_event = function (device, value) + -- powerConsumptionReport report interval + local current_time = os.time() + local last_time = device:get_field(LAST_REPORT_TIME) or 0 + local next_time = last_time + 60 * 15 -- 15 mins, the minimum interval allowed between reports + if current_time < next_time then + return + end + device:set_field(LAST_REPORT_TIME, current_time, { persist = true }) + local raw_value = value.value * 1000 -- 'Wh' + + local delta_energy = 0.0 + local current_power_consumption = device:get_latest_state('main', capabilities.powerConsumptionReport.ID, capabilities.powerConsumptionReport.powerConsumption.NAME) + if current_power_consumption ~= nil then + delta_energy = math.max(raw_value - current_power_consumption.energy, 0.0) + end + device:emit_event(capabilities.powerConsumptionReport.powerConsumption({ + energy = raw_value, + deltaEnergy = delta_energy + })) +end + +return power_consumption \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/sub_drivers.lua b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/sub_drivers.lua new file mode 100644 index 0000000000..98c3fb45f8 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/aeotec-home-energy-meter-gen8/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aeotec-home-energy-meter-gen8.1-phase"), + lazy_load_if_possible("aeotec-home-energy-meter-gen8.2-phase"), + lazy_load_if_possible("aeotec-home-energy-meter-gen8.3-phase") +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-electric-meter/src/init.lua b/drivers/SmartThings/zwave-electric-meter/src/init.lua index 2ef9f20281..53c91cfa90 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/init.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/init.lua @@ -1,27 +1,37 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults local defaults = require "st.zwave.defaults" --- @type st.zwave.Driver local ZwaveDriver = require "st.zwave.driver" +--- @type st.zwave.CommandClass.Configuration +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=1}) + +local preferencesMap = require "preferences" local device_added = function (self, device) device:refresh() end +--- Handle preference changes +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param event table +--- @param args +local function info_changed(driver, device, event, args) + local preferences = preferencesMap.get_device_parameters(device) + for id, value in pairs(device.preferences) do + if args.old_st_store.preferences[id] ~= value and preferences and preferences[id] then + local new_parameter_value = preferencesMap.to_numeric_value(device.preferences[id]) + device:send(Configuration:Set({ parameter_number = preferences[id].parameter_number, size = preferences[id].size, configuration_value = new_parameter_value })) + end + end +end + local driver_template = { supported_capabilities = { capabilities.powerMeter, @@ -29,13 +39,11 @@ local driver_template = { capabilities.refresh }, lifecycle_handlers = { + infoChanged = info_changed, added = device_added }, - sub_drivers = { - require("qubino-meter"), - require("aeotec-gen5-meter"), - require("aeon-meter") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-electric-meter/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-electric-meter/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-electric-meter/src/preferences.lua b/drivers/SmartThings/zwave-electric-meter/src/preferences.lua new file mode 100644 index 0000000000..b1f14c25bd --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/preferences.lua @@ -0,0 +1,95 @@ +local devices = { + AEOTEC_HOME_ENERGY_METER_GEN8_1_PHASE = { + MATCHING_MATRIX = { + mfrs = 0x0371, + product_types = {0x0003, 0x0102 }, + product_ids = 0x0033 + }, + PARAMETERS = { + thresholdCheck = {parameter_number = 3, size = 1}, + imWThresholdTotal = {parameter_number = 4, size = 2}, + imWThresholdPhaseA = {parameter_number = 5, size = 2}, + exWThresholdTotal = {parameter_number = 8, size = 2}, + exWThresholdPhaseA = {parameter_number = 9, size = 2}, + imtWPctThresholdTotal = {parameter_number = 12, size = 1}, + imWPctThresholdPhaseA = {parameter_number = 13, size = 1}, + exWPctThresholdTotal = {parameter_number = 16, size = 1}, + exWPctThresholdPhaseA = {parameter_number = 17, size = 1}, + autoRootDeviceReport = {parameter_number = 32, size = 1}, + } + }, + AEOTEC_HOME_ENERGY_METER_GEN8_2_PHASE = { + MATCHING_MATRIX = { + mfrs = 0x0371, + product_types = 0x0103, + product_ids = 0x002E + }, + PARAMETERS = { + thresholdCheck = {parameter_number = 3, size = 1}, + imWThresholdTotal = {parameter_number = 4, size = 2}, + imWThresholdPhaseA = {parameter_number = 5, size = 2}, + imWThresholdPhaseB = {parameter_number = 6, size = 2}, + exWThresholdTotal = {parameter_number = 8, size = 2}, + exWThresholdPhaseA = {parameter_number = 9, size = 2}, + exWThresholdPhaseB = {parameter_number = 10, size = 2}, + imtWPctThresholdTotal = {parameter_number = 12, size = 1}, + imWPctThresholdPhaseA = {parameter_number = 13, size = 1}, + imWPctThresholdPhaseB = {parameter_number = 14, size = 1}, + exWPctThresholdTotal = {parameter_number = 16, size = 1}, + exWPctThresholdPhaseA = {parameter_number = 17, size = 1}, + exWPctThresholdPhaseB = {parameter_number = 18, size = 1}, + autoRootDeviceReport = {parameter_number = 32, size = 1}, + } + }, + AEOTEC_HOME_ENERGY_METER_GEN8_3_PHASE = { + MATCHING_MATRIX = { + mfrs = 0x0371, + product_types = {0x0003, 0x0102}, + product_ids = 0x0034 + }, + PARAMETERS = { + thresholdCheck = {parameter_number = 3, size = 1}, + imWThresholdTotal = {parameter_number = 4, size = 2}, + imWThresholdPhaseA = {parameter_number = 5, size = 2}, + imWThresholdPhaseB = {parameter_number = 6, size = 2}, + imWThresholdPhaseC = {parameter_number = 7, size = 2}, + exWThresholdTotal = {parameter_number = 8, size = 2}, + exWThresholdPhaseA = {parameter_number = 9, size = 2}, + exWThresholdPhaseB = {parameter_number = 10, size = 2}, + exWThresholdPhaseC = {parameter_number = 11, size = 2}, + imtWPctThresholdTotal = {parameter_number = 12, size = 1}, + imWPctThresholdPhaseA = {parameter_number = 13, size = 1}, + imWPctThresholdPhaseB = {parameter_number = 14, size = 1}, + imWPctThresholdPhaseC = {parameter_number = 15, size = 1}, + exWPctThresholdTotal = {parameter_number = 16, size = 1}, + exWPctThresholdPhaseA = {parameter_number = 17, size = 1}, + exWPctThresholdPhaseB = {parameter_number = 18, size = 1}, + exWPctThresholdPhaseC = {parameter_number = 19, size = 1}, + autoRootDeviceReport = {parameter_number = 32, size = 1}, + } + } +} + +local preferences = {} + +preferences.get_device_parameters = function(zw_device) + for _, device in pairs(devices) do + if zw_device:id_match( + device.MATCHING_MATRIX.mfrs, + device.MATCHING_MATRIX.product_types, + device.MATCHING_MATRIX.product_ids) then + return device.PARAMETERS + end + end + return nil +end + +preferences.to_numeric_value = function(new_value) + local numeric = tonumber(new_value) + if numeric == nil then -- in case the value is boolean + numeric = new_value and 1 or 0 + end + return numeric +end + +return preferences diff --git a/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/can_handle.lua b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/can_handle.lua new file mode 100644 index 0000000000..dd9ae17949 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_qubino_meter(opts, driver, device, ...) + local FINGERPRINTS = require("qubino-meter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("qubino-meter") + end + end + return false +end + +return can_handle_qubino_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/fingerprints.lua b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/fingerprints.lua new file mode 100644 index 0000000000..5a5fd44be5 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local QUBINO_FINGERPRINTS = { + {mfr = 0x0159, prod = 0x0007, model = 0x0054}, -- Qubino 3 Phase Meter + {mfr = 0x0159, prod = 0x0007, model = 0x0052} -- Qubino Smart Meter +} + +return QUBINO_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/init.lua b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/init.lua index a8e2b23805..4c2a40a56e 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/init.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/qubino-meter/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.Configuration @@ -20,22 +10,10 @@ local Meter = (require "st.zwave.CommandClass.Meter")({ version=3 }) --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" -local QUBINO_FINGERPRINTS = { - {mfr = 0x0159, prod = 0x0007, model = 0x0054}, -- Qubino 3 Phase Meter - {mfr = 0x0159, prod = 0x0007, model = 0x0052} -- Qubino Smart Meter -} local POWER_UNIT_WATT = "W" local ENERGY_UNIT_KWH = "kWh" -local function can_handle_qubino_meter(opts, driver, device, ...) - for _, fingerprint in ipairs(QUBINO_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function meter_report_handler(self, device, cmd) @@ -101,7 +79,7 @@ local qubino_meter = { init = device_init }, NAME = "qubino meter", - can_handle = can_handle_qubino_meter + can_handle = require("qubino-meter.can_handle"), } return qubino_meter diff --git a/drivers/SmartThings/zwave-electric-meter/src/sub_drivers.lua b/drivers/SmartThings/zwave-electric-meter/src/sub_drivers.lua new file mode 100644 index 0000000000..3c0e482bda --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("qubino-meter"), + lazy_load_if_possible("aeotec-gen5-meter"), + lazy_load_if_possible("aeon-meter"), + lazy_load_if_possible("aeotec-home-energy-meter-gen8"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeon_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeon_meter.lua index d82e1549fa..ed4d4f3609 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeon_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeon_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -110,7 +106,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -140,7 +137,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 103, size = 4, configuration_value = 0}) )) mock_meter:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_gen5_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_gen5_meter.lua index 09b897ba9d..9534c5d2e1 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_gen5_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_gen5_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -110,7 +106,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -144,7 +141,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 13, size = 1, configuration_value = 0}) )) mock_meter:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() 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 new file mode 100644 index 0000000000..484ba90c7c --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_1_phase.lua @@ -0,0 +1,561 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Meter = (require "st.zwave.CommandClass.Meter")({version=4}) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local t_utils = require "integration_test.utils" + +local AEOTEC_MFR_ID = 0x0371 +local AEOTEC_METER_PROD_TYPE = 0x0003 +local AEOTEC_METER_PROD_ID = 0x0033 + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_meter_endpoints = { + { + command_classes = { + {value = zw.METER} + } + } +} + +local HEM8_DEVICES = { + { + profile = 'aeotec-home-energy-meter-gen8-1-phase-con', + name = 'Aeotec Home Energy Meter 8 Consumption', + endpoints = { 1, 3 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-1-phase-pro', + name = 'Aeotec Home Energy Meter 8 Production', + child_key = 'pro', + endpoints = { 2, 4 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-con', + name = 'Aeotec Home Energy Meter 8 Settled Consumption', + child_key = 'sald-con', + endpoints = { 5 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-pro', + name = 'Aeotec Home Energy Meter 8 Settled Production', + child_key = 'sald-pro', + endpoints = { 6 } + } +} + +local mock_parent = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[1].profile .. '.yml'), + zwave_endpoints = aeotec_meter_endpoints, + zwave_manufacturer_id = AEOTEC_MFR_ID, + zwave_product_type = AEOTEC_METER_PROD_TYPE, + zwave_product_id = AEOTEC_METER_PROD_ID +}) + +local mock_child_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[2].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[2].child_key +}) + +local mock_child_sald_con = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[3].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[3].child_key +}) + +local mock_child_sald_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[4].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[4].child_key +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent) + test.mock_device.add_test_device(mock_child_prod) + test.mock_device.add_test_device(mock_child_sald_con) + test.mock_device.add_test_device(mock_child_sald_prod) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Added lifecycle event should create children for parent device", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "added" }) + + for _, child in ipairs(HEM8_DEVICES) do + if(child["child_key"]) then + mock_parent:expect_device_create( + { + type = "EDGE_CHILD", + label = child.name, + profile = child.profile, + parent_device_id = mock_parent.id, + parent_assigned_child_key = child.child_key + } + ) + end + end + -- Refresh + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "doConfigure" }) + + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 111, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 112, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 113, size = 4, configuration_value = 300}) + )) + mock_parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Power meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 3 and endpoint ~= 4 and endpoint ~= 5 and endpoint ~= 6 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 27 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Energy meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 3 and endpoint ~= 4 and endpoint ~= 5 and endpoint ~= 6 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + -- set time to trigger power consumption report + local current_time = os.time() - 60 * 20 + --mock_child_sald_con:set_field(LAST_REPORT_TIME, current_time) + mock_parent:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zwave:__queue_receive( + { + --mock_child_sald_con.id, + mock_parent.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, + { + encap = zw.ENCAP.AUTO, + src_channel = 5, + dst_channels = {0} + } + )) + } + ) + + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + -- mock_parent + mock_parent:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end +) + +test.register_coroutine_test( + "Handle preference: thresholdCheck (parameter 3) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + thresholdCheck = 0 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 3, + configuration_value = 0, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdTotal (parameter 4) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 4, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseA (parameter 5) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 5, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdTotal (parameter 8) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 8, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseA (parameter 9) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 9, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imtWPctThresholdTotal (parameter 12) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imtWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 12, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseA (parameter 13) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 13, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdTotal (parameter 16) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 16, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseA (parameter 17) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 17, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: autoRootDeviceReport (parameter 32) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + autoRootDeviceReport = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 32, + configuration_value = 1, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Refresh sends commands to all components including base device", + function() + -- refresh commands for zwave devices do not have guaranteed ordering + test.socket.zwave:__set_channel_ordering("relaxed") + + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + + test.socket.capability:__queue_receive({ + mock_parent.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + }) + end +) + +test.run_registered_tests() \ No newline at end of file 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 new file mode 100644 index 0000000000..7ab752246a --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_2_phase.lua @@ -0,0 +1,655 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Meter = (require "st.zwave.CommandClass.Meter")({version=4}) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local t_utils = require "integration_test.utils" + +local AEOTEC_MFR_ID = 0x0371 +local AEOTEC_METER_PROD_TYPE = 0x0103 +local AEOTEC_METER_PROD_ID = 0x002E + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_meter_endpoints = { + { + command_classes = { + {value = zw.METER} + } + } +} + +local HEM8_DEVICES = { + { + profile = 'aeotec-home-energy-meter-gen8-2-phase-con', + name = 'Aeotec Home Energy Meter 8 Consumption', + endpoints = { 1, 3, 5 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-2-phase-pro', + name = 'Aeotec Home Energy Meter 8 Production', + child_key = 'pro', + endpoints = { 2, 4, 6 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-con', + name = 'Aeotec Home Energy Meter 8 Settled Consumption', + child_key = 'sald-con', + endpoints = { 7 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-pro', + name = 'Aeotec Home Energy Meter 8 Settled Production', + child_key = 'sald-pro', + endpoints = { 8 } + } +} + +local mock_parent = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[1].profile .. '.yml'), + zwave_endpoints = aeotec_meter_endpoints, + zwave_manufacturer_id = AEOTEC_MFR_ID, + zwave_product_type = AEOTEC_METER_PROD_TYPE, + zwave_product_id = AEOTEC_METER_PROD_ID +}) + +local mock_child_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[2].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[2].child_key +}) + +local mock_child_sald_con = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[3].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[3].child_key +}) + +local mock_child_sald_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[4].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[4].child_key +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent) + test.mock_device.add_test_device(mock_child_prod) + test.mock_device.add_test_device(mock_child_sald_con) + test.mock_device.add_test_device(mock_child_sald_prod) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Added lifecycle event should create children for parent device", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "added" }) + + for _, child in ipairs(HEM8_DEVICES) do + if(child["child_key"]) then + mock_parent:expect_device_create( + { + type = "EDGE_CHILD", + label = child.name, + profile = child.profile, + parent_device_id = mock_parent.id, + parent_assigned_child_key = child.child_key + } + ) + end + end + -- Refresh + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "doConfigure" }) + + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 111, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 112, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 113, size = 4, configuration_value = 300}) + )) + mock_parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Power meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 5 and endpoint ~= 6 and endpoint ~= 7 and endpoint ~= 8 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 27 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Energy meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 5 and endpoint ~= 6 and endpoint ~= 7 and endpoint ~= 8 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + -- set time to trigger power consumption report + local current_time = os.time() - 60 * 20 + --mock_child_sald_con:set_field(LAST_REPORT_TIME, current_time) + mock_parent:set_field(LAST_REPORT_TIME, current_time) + + test.socket.zwave:__queue_receive( + { + mock_parent.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, + { + encap = zw.ENCAP.AUTO, + src_channel = 7, + dst_channels = {0} + } + )) + } + ) + + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + mock_parent:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end +) + +test.register_coroutine_test( + "Handle preference: thresholdCheck (parameter 3) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + thresholdCheck = 0 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 3, + configuration_value = 0, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdTotal (parameter 4) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 4, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseA (parameter 5) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 5, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseB (parameter 6) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseB = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 6, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdTotal (parameter 8) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 8, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseA (parameter 9) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 9, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseB (parameter 10) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseB = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 10, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imtWPctThresholdTotal (parameter 12) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imtWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 12, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseA (parameter 13) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 13, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseB (parameter 14) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseB = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 14, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdTotal (parameter 16) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 16, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseA (parameter 17) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 17, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseB (parameter 18) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseB = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 18, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: autoRootDeviceReport (parameter 32) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + autoRootDeviceReport = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 32, + configuration_value = 1, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Refresh sends commands to all components including base device", + function() + -- refresh commands for zwave devices do not have guaranteed ordering + test.socket.zwave:__set_channel_ordering("relaxed") + + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + + test.socket.capability:__queue_receive({ + mock_parent.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + }) + end +) + +test.run_registered_tests() 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 new file mode 100644 index 0000000000..c1fedbf717 --- /dev/null +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_aeotec_home_energy_meter_gen8_3_phase.lua @@ -0,0 +1,750 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Meter = (require "st.zwave.CommandClass.Meter")({version=4}) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) +local t_utils = require "integration_test.utils" + +local AEOTEC_MFR_ID = 0x0371 +local AEOTEC_METER_PROD_TYPE = 0x0003 +local AEOTEC_METER_PROD_ID = 0x0034 + +local LAST_REPORT_TIME = "LAST_REPORT_TIME" + +local aeotec_meter_endpoints = { + { + command_classes = { + {value = zw.METER} + } + } +} + +local HEM8_DEVICES = { + { + profile = 'aeotec-home-energy-meter-gen8-3-phase-con', + name = 'Aeotec Home Energy Meter 8 Consumption', + endpoints = { 1, 3, 5, 7 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-3-phase-pro', + name = 'Aeotec Home Energy Meter 8 Production', + child_key = 'pro', + endpoints = { 2, 4, 6, 8 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-con', + name = 'Aeotec Home Energy Meter 8 Settled Consumption', + child_key = 'sald-con', + endpoints = { 9 } + }, + { + profile = 'aeotec-home-energy-meter-gen8-sald-pro', + name = 'Aeotec Home Energy Meter 8 Settled Production', + child_key = 'sald-pro', + endpoints = { 10 } + } +} + +local mock_parent = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[1].profile .. '.yml'), + zwave_endpoints = aeotec_meter_endpoints, + zwave_manufacturer_id = AEOTEC_MFR_ID, + zwave_product_type = AEOTEC_METER_PROD_TYPE, + zwave_product_id = AEOTEC_METER_PROD_ID +}) + +local mock_child_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[2].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[2].child_key +}) + +local mock_child_sald_con = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[3].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[3].child_key +}) + +local mock_child_sald_prod = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition(HEM8_DEVICES[4].profile .. '.yml'), + parent_device_id = mock_parent.id, + parent_assigned_child_key = HEM8_DEVICES[4].child_key +}) + +local function test_init() + test.mock_device.add_test_device(mock_parent) + test.mock_device.add_test_device(mock_child_prod) + test.mock_device.add_test_device(mock_child_sald_con) + test.mock_device.add_test_device(mock_child_sald_prod) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Added lifecycle event should create children for parent device", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "added" }) + + for _, child in ipairs(HEM8_DEVICES) do + if(child["child_key"]) then + mock_parent:expect_device_create( + { + type = "EDGE_CHILD", + label = child.name, + profile = child.profile, + parent_device_id = mock_parent.id, + parent_assigned_child_key = child.child_key + } + ) + end + end + -- Refresh + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + end, + {test_init = function() test.mock_device.add_test_device(mock_parent) end} +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_parent.id, "doConfigure" }) + + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 111, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 112, size = 4, configuration_value = 300}) + )) + test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({parameter_number = 113, size = 4, configuration_value = 300}) + )) + mock_parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Power meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 7 and endpoint ~= 8 and endpoint ~= 9 and endpoint ~= 10 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 27 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.powerMeter.power({ value = 27, unit = "W" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Energy meter report should be handled", + function() + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + local component = "main" + if endpoint ~= 7 and endpoint ~= 8 and endpoint ~= 9 and endpoint ~= 10 then + component = string.format("clamp%d", endpoint) + end + test.socket.zwave:__queue_receive({ + mock_parent.id, + Meter:Report({ + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, { + encap = zw.ENCAP.AUTO, + src_channel = endpoint, + dst_channels = {0} + }) + }) + if(device["child_key"]) then + if(device["child_key"] == "pro") then + test.socket.capability:__expect_send( + mock_child_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-pro") then + test.socket.capability:__expect_send( + mock_child_sald_prod:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + elseif (device["child_key"] == "sald-con") then + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + else + test.socket.capability:__expect_send( + mock_parent:generate_test_message(component, capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + end + end + end + end +) + +test.register_coroutine_test( + "Report consumption and power consumption report after 15 minutes", function() + -- set time to trigger power consumption report + local current_time = os.time() - 60 * 20 + -- mock_child_sald_con:set_field(LAST_REPORT_TIME, current_time) + mock_parent:set_field(LAST_REPORT_TIME, current_time) + test.socket.zwave:__queue_receive( + { + mock_parent.id, + zw_test_utils.zwave_test_build_receive_command(Meter:Report( + { + scale = Meter.scale.electric_meter.KILOWATT_HOURS, + meter_value = 5 + }, + { + encap = zw.ENCAP.AUTO, + src_channel = 9, + dst_channels = {0} + } + )) + } + ) + + test.socket.capability:__expect_send( + mock_child_sald_con:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) + ) + + test.socket.capability:__expect_send( + mock_parent:generate_test_message("main", + capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) + ) + end +) + +test.register_coroutine_test( + "Handle preference: thresholdCheck (parameter 3) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + thresholdCheck = 0 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 3, + configuration_value = 0, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdTotal (parameter 4) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 4, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseA (parameter 5) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 5, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseB (parameter 6) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseB = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 6, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWThresholdPhaseC (parameter 7) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWThresholdPhaseC = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 7, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdTotal (parameter 8) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdTotal = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 8, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseA (parameter 9) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseA = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 9, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseB (parameter 10) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseB = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 10, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWThresholdPhaseC (parameter 11) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWThresholdPhaseC = 3500 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 11, + configuration_value = 3500, + size = 2 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imtWPctThresholdTotal (parameter 12) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imtWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 12, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseA (parameter 13) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 13, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseB (parameter 14) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseB = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 14, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: imWPctThresholdPhaseC (parameter 15) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + imWPctThresholdPhaseC = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 15, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdTotal (parameter 16) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdTotal = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 16, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseA (parameter 17) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseA = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 17, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: exWPctThresholdPhaseB (parameter 18) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseB = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 18, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: thresholdCheck (exWPctThresholdPhaseC 19) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + exWPctThresholdPhaseC = 50 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 19, + configuration_value = 50, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Handle preference: autoRootDeviceReport (parameter 32) in infoChanged", + function() + test.socket.device_lifecycle:__queue_receive( + mock_parent:generate_info_changed({ + preferences = { + autoRootDeviceReport = 1 + } + }) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Configuration:Set({ + parameter_number = 32, + configuration_value = 1, + size = 1 + }) + ) + ) + end +) + +test.register_coroutine_test( + "Refresh sends commands to all components including base device", + function() + -- refresh commands for zwave devices do not have guaranteed ordering + test.socket.zwave:__set_channel_ordering("relaxed") + + for _, device in ipairs(HEM8_DEVICES) do + for _, endpoint in ipairs(device.endpoints) do + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.WATTS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}, { + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = { endpoint } + }) + ) + ) + end + end + + test.socket.capability:__queue_receive({ + mock_parent.id, + { capability = "refresh", component = "main", command = "refresh", args = { } } + }) + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_3_phase_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_3_phase_meter.lua index dad4b1030e..fad1510245 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_3_phase_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_3_phase_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -104,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("endpointMeter3", capabilities.powerMeter.power({ value = 5, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -123,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) }, + }, + { + min_api_version = 14 } ) @@ -182,7 +178,10 @@ test.register_coroutine_test( { capability = "refresh", component = "main", command = "refresh", args = { } } }) - end + end, + { + min_api_version = 14 + } ) @@ -221,7 +220,10 @@ test.register_coroutine_test( ) )) mock_meter:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_smart_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_smart_meter.lua index 11a1317a74..e97be3bde3 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_smart_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_qubino_smart_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -110,7 +106,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -128,7 +125,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 42, size = 2, configuration_value = 1800}) )) mock_meter:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-electric-meter/src/test/test_zwave_electric_meter.lua b/drivers/SmartThings/zwave-electric-meter/src/test/test_zwave_electric_meter.lua index cf640c3655..436862230d 100644 --- a/drivers/SmartThings/zwave-electric-meter/src/test/test_zwave_electric_meter.lua +++ b/drivers/SmartThings/zwave-electric-meter/src/test/test_zwave_electric_meter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -55,6 +45,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -74,6 +67,9 @@ test.register_message_test( direction = "send", message = mock_meter:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -103,7 +99,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-fan/src/init.lua b/drivers/SmartThings/zwave-fan/src/init.lua index acdb34ae76..d3b51d8344 100644 --- a/drivers/SmartThings/zwave-fan/src/init.lua +++ b/drivers/SmartThings/zwave-fan/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -27,10 +17,8 @@ local driver_template = { capabilities.switch, capabilities.fanSpeed, }, - sub_drivers = { - require("zwave-fan-3-speed"), - require("zwave-fan-4-speed") - }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-fan/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-fan/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-fan/src/sub_drivers.lua b/drivers/SmartThings/zwave-fan/src/sub_drivers.lua new file mode 100644 index 0000000000..373e4daf52 --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-fan-3-speed"), + lazy_load_if_possible("zwave-fan-4-speed"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_3_speed.lua b/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_3_speed.lua index 249c0ebc14..179baed5aa 100644 --- a/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_3_speed.lua +++ b/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_3_speed.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -72,6 +62,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -101,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -131,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -159,6 +158,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -190,7 +192,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_4_speed.lua b/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_4_speed.lua index 18aab08c73..fc8a5b742c 100644 --- a/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_4_speed.lua +++ b/drivers/SmartThings/zwave-fan/src/test/test_zwave_fan_4_speed.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -71,6 +61,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -99,6 +92,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -129,6 +125,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -157,6 +156,9 @@ test.register_message_test( direction = "send", message = mock_fan:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -188,7 +190,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/can_handle.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/can_handle.lua new file mode 100644 index 0000000000..66a04b41a9 --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function is_fan_3_speed(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-fan-3-speed.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-fan-3-speed") + end + end + return false +end + +return is_fan_3_speed diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/fingerprints.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/fingerprints.lua new file mode 100644 index 0000000000..7241769dcd --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/fingerprints.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FAN_3_SPEED_FINGERPRINTS = { + {mfr = 0x001D, prod = 0x1001, model = 0x0334}, -- Leviton 3-Speed Fan Controller + {mfr = 0x0063, prod = 0x4944, model = 0x3034}, -- GE In-Wall Smart Fan Control + {mfr = 0x0063, prod = 0x4944, model = 0x3131}, -- GE In-Wall Smart Fan Control + {mfr = 0x0039, prod = 0x4944, model = 0x3131}, -- Honeywell Z-Wave Plus In-Wall Fan Speed Control + {mfr = 0x0063, prod = 0x4944, model = 0x3337}, -- GE In-Wall Smart Fan Control +} + +return FAN_3_SPEED_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/init.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/init.lua index 282f2e35db..ad629ca54c 100644 --- a/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/init.lua +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-3-speed/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local log = require "log" local capabilities = require "st.capabilities" @@ -22,13 +12,6 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=4 }) local fan_speed_helper = (require "zwave_fan_helpers") -local FAN_3_SPEED_FINGERPRINTS = { - {mfr = 0x001D, prod = 0x1001, model = 0x0334}, -- Leviton 3-Speed Fan Controller - {mfr = 0x0063, prod = 0x4944, model = 0x3034}, -- GE In-Wall Smart Fan Control - {mfr = 0x0063, prod = 0x4944, model = 0x3131}, -- GE In-Wall Smart Fan Control - {mfr = 0x0039, prod = 0x4944, model = 0x3131}, -- Honeywell Z-Wave Plus In-Wall Fan Speed Control - {mfr = 0x0063, prod = 0x4944, model = 0x3337}, -- GE In-Wall Smart Fan Control -} local function map_fan_3_speed_to_switch_level (speed) if speed == fan_speed_helper.fan_speed.OFF then @@ -63,14 +46,6 @@ end --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is an 3-speed fan, else false -local function is_fan_3_speed(opts, driver, device, ...) - for _, fingerprint in ipairs(FAN_3_SPEED_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local capability_handlers = {} @@ -110,7 +85,8 @@ local zwave_fan_3_speed = { } }, NAME = "Z-Wave fan 3 speed", - can_handle = is_fan_3_speed, + can_handle = require("zwave-fan-3-speed.can_handle"), + shared_device_thread_enabled = true, } return zwave_fan_3_speed diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/can_handle.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/can_handle.lua new file mode 100644 index 0000000000..b67f3c2ec7 --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fan_4_speed(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-fan-4-speed.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("zwave-fan-4-speed") + end + end + return false +end + +return can_handle_fan_4_speed diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/fingerprints.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/fingerprints.lua new file mode 100644 index 0000000000..2e2f2b13fb --- /dev/null +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FAN_4_SPEED_FINGERPRINTS = { + {mfr = 0x001D, prod = 0x0038, model = 0x0002}, -- Leviton 4-Speed Fan Controller +} + +return FAN_4_SPEED_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/init.lua b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/init.lua index f714a56b1b..62ec23bfa0 100644 --- a/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/init.lua +++ b/drivers/SmartThings/zwave-fan/src/zwave-fan-4-speed/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local log = require "log" local capabilities = require "st.capabilities" @@ -22,9 +12,6 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=4 }) local fan_speed_helper = (require "zwave_fan_helpers") -local FAN_4_SPEED_FINGERPRINTS = { - {mfr = 0x001D, prod = 0x0038, model = 0x0002}, -- Leviton 4-Speed Fan Controller -} local function map_fan_4_speed_to_switch_level (speed) if speed == fan_speed_helper.fan_speed.OFF then @@ -64,14 +51,6 @@ end --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is 4-speed fan, else false -local function can_handle_fan_4_speed(opts, driver, device, ...) - for _, fingerprint in ipairs(FAN_4_SPEED_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local capability_handlers = {} @@ -111,7 +90,8 @@ local zwave_fan_4_speed = { } }, NAME = "Z-Wave fan 4 speed", - can_handle = can_handle_fan_4_speed, + can_handle = require("zwave-fan-4-speed.can_handle"), + shared_device_thread_enabled = true, } return zwave_fan_4_speed diff --git a/drivers/SmartThings/zwave-fan/src/zwave_fan_helpers.lua b/drivers/SmartThings/zwave-fan/src/zwave_fan_helpers.lua index bb056cf06a..38c26fa78c 100644 --- a/drivers/SmartThings/zwave-fan/src/zwave_fan_helpers.lua +++ b/drivers/SmartThings/zwave-fan/src/zwave_fan_helpers.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.SwitchMultilevel 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-garage-door-opener/src/ecolink-zw-gdo/can_handle.lua b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/can_handle.lua new file mode 100644 index 0000000000..571ba6bce1 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ecolink_garage_door(opts, driver, device, ...) + local FINGERPRINTS = require("ecolink-zw-gdo.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("ecolink-zw-gdo") + end + end + return false +end + +return can_handle_ecolink_garage_door diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/fingerprints.lua b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/fingerprints.lua new file mode 100644 index 0000000000..15de27ad0b --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Ecolink garage door operator +local ECOLINK_GARAGE_DOOR_FINGERPRINTS = { + {manufacturerId = 0x014A, productType = 0x0007, productId = 0x4731}, +} + +return ECOLINK_GARAGE_DOOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/init.lua b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/init.lua index e6638841be..1019056330 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/init.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/ecolink-zw-gdo/init.lua @@ -1,16 +1,5 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.capabilities local capabilities = require "st.capabilities" @@ -28,11 +17,6 @@ local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ ve --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 8 }) --- Ecolink garage door operator -local ECOLINK_GARAGE_DOOR_FINGERPRINTS = { - manufacturerId = 0x014A, productType = 0x0007, productId = 0x4731 -} - local GDO_ENDPOINT_NAME = "main" local CONTACTSENSOR_ENDPOINT_NAME = "sensor" local GDO_ENDPOINT_NUMBER = 1 @@ -55,11 +39,6 @@ local GDO_CONFIG_PARAMS = { --- @param driver Driver driver instance --- @param device Device device isntance --- @return boolean true if the device proper, else false -local function can_handle_ecolink_garage_door(opts, driver, device, ...) - return device:id_match(ECOLINK_GARAGE_DOOR_FINGERPRINTS.manufacturerId, - ECOLINK_GARAGE_DOOR_FINGERPRINTS.productType, - ECOLINK_GARAGE_DOOR_FINGERPRINTS.productId) -end local function component_to_endpoint(device, component_id) if (CONTACTSENSOR_ENDPOINT_NAME == component_id) then @@ -282,7 +261,8 @@ local ecolink_garage_door_operator = { doConfigure = configure_device_with_updated_config, infoChanged = configure_device_with_updated_config }, - can_handle = can_handle_ecolink_garage_door + can_handle = require("ecolink-zw-gdo.can_handle"), + shared_device_thread_enabled = true, } return ecolink_garage_door_operator diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/init.lua b/drivers/SmartThings/zwave-garage-door-opener/src/init.lua index 6d0a0a880c..3286471d83 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/init.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.Driver @@ -24,10 +14,8 @@ local driver_template = { capabilities.doorControl, capabilities.contactSensor, }, - sub_drivers = { - require("mimolite-garage-door"), - require("ecolink-zw-gdo") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-garage-door-opener/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/can_handle.lua b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/can_handle.lua new file mode 100644 index 0000000000..e515aae646 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_mimolite_garage_door(opts, driver, device, ...) + local FINGERPRINTS = require("mimolite-garage-door.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("mimolite-garage-door") + end + end + return false +end + +return can_handle_mimolite_garage_door diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/fingerprints.lua b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/fingerprints.lua new file mode 100644 index 0000000000..52f0969e84 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local MIMOLITE_GARAGE_DOOR_FINGERPRINTS = { + { manufacturerId = 0x0084, productType = 0x0453, productId = 0x0111 } -- mimolite garage door +} + +return MIMOLITE_GARAGE_DOOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/init.lua b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/init.lua index 1fd1b4362b..138a4f1b1d 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/init.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/mimolite-garage-door/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -28,23 +18,12 @@ local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = --- @type st.zwave.CommandClass.SwitchBinary local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) -local MIMOLITE_GARAGE_DOOR_FINGERPRINTS = { - { manufacturerId = 0x0084, productType = 0x0453, productId = 0x0111 } -- mimolite garage door -} --- Determine whether the passed device is mimolite garage door --- --- @param driver Driver driver instance --- @param device Device device isntance --- @return boolean true if the device proper, else false -local function can_handle_mimolite_garage_door(opts, driver, device, ...) - for _, fingerprint in ipairs(MIMOLITE_GARAGE_DOOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function door_event_helper(device, value) device:emit_event(value == 0x00 and capabilities.doorControl.door.closed() or capabilities.doorControl.door.open()) @@ -118,7 +97,8 @@ local mimolite_garage_door = { doConfigure = do_configure }, NAME = "mimolite garage door", - can_handle = can_handle_mimolite_garage_door + can_handle = require("mimolite-garage-door.can_handle"), + shared_device_thread_enabled = true, } return mimolite_garage_door diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/sub_drivers.lua b/drivers/SmartThings/zwave-garage-door-opener/src/sub_drivers.lua new file mode 100644 index 0000000000..8de6edfd56 --- /dev/null +++ b/drivers/SmartThings/zwave-garage-door-opener/src/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("mimolite-garage-door"), + lazy_load_if_possible("ecolink-zw-gdo"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_ecolink_garage_door_operator.lua b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_ecolink_garage_door_operator.lua index c37e99da13..ae86832e4d 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_ecolink_garage_door_operator.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_ecolink_garage_door_operator.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.doorControl.door.closed()) } + }, + { + min_api_version = 14 } ) @@ -83,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.doorControl.door.open()) } + }, + { + min_api_version = 14 } ) @@ -123,7 +119,8 @@ test.register_message_test( }, { test_init = test_init, - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -162,7 +159,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -183,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 12.2999999, unit = 'C'})) } + }, + { + min_api_version = 14 } ) @@ -203,6 +204,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 45.6, unit = 'F'})) } + }, + { + min_api_version = 14 } ) @@ -228,7 +232,10 @@ test.register_coroutine_test( parameters = updated_params}) )) mock_garage_door:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -255,7 +262,10 @@ test.register_coroutine_test( BarrierOperator:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -282,7 +292,11 @@ test.register_coroutine_test( BarrierOperator:Get({}) ) ) - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -295,7 +309,11 @@ test.register_coroutine_test( ) test.wait_for_events() test.mock_time.advance_time(1) - end + end, + { + min_api_version = 14 + } + ) test.register_coroutine_test( @@ -308,7 +326,11 @@ test.register_coroutine_test( ) test.wait_for_events() test.mock_time.advance_time(1) - end + end, + { + min_api_version = 14 + } + ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_mimolite_garage_door.lua b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_mimolite_garage_door.lua index c10d3f8fa5..946978e0b0 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_mimolite_garage_door.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_mimolite_garage_door.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -88,6 +81,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -109,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -130,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -151,6 +153,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -172,6 +177,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -203,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.doorControl.door.closing()) } + }, + { + min_api_version = 14 } ) @@ -234,6 +245,9 @@ test.register_message_test( direction = "send", message = mock_garage_door:generate_test_message("main", capabilities.doorControl.door.opening()) } + }, + { + min_api_version = 14 } ) @@ -261,7 +275,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -288,7 +305,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -305,7 +325,10 @@ test.register_coroutine_test( Association:Set({grouping_identifier = 3, node_ids = {}}) )) mock_garage_door:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -326,7 +349,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_zwave_garage_door_opener.lua b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_zwave_garage_door_opener.lua index 65c0256149..366824a8d2 100644 --- a/drivers/SmartThings/zwave-garage-door-opener/src/test/test_zwave_garage_door_opener.lua +++ b/drivers/SmartThings/zwave-garage-door-opener/src/test/test_zwave_garage_door_opener.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -58,6 +48,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -74,6 +67,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.doorControl.door.closing()) } + }, + { + min_api_version = 14 } ) @@ -95,6 +91,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -116,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -132,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.doorControl.door.unknown()) } + }, + { + min_api_version = 14 } ) @@ -150,7 +155,8 @@ test.register_message_test( } }, { - test_init = test_init + test_init = test_init, + min_api_version = 14 } ) @@ -170,7 +176,8 @@ test.register_message_test( }, { test_init = test_init, - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml b/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml index 5fbfb13f3d..e3a25ab57a 100644 --- a/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml +++ b/drivers/SmartThings/zwave-lock/profiles/base-lock-tamper.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: tamperAlert diff --git a/drivers/SmartThings/zwave-lock/profiles/base-lock.yml b/drivers/SmartThings/zwave-lock/profiles/base-lock.yml index f4957f9ad0..efb97c8b27 100644 --- a/drivers/SmartThings/zwave-lock/profiles/base-lock.yml +++ b/drivers/SmartThings/zwave-lock/profiles/base-lock.yml @@ -6,6 +6,10 @@ components: version: 1 - id: lockCodes version: 1 + - id: lockCredentials + version: 1 + - id: lockUsers + version: 1 - id: battery version: 1 - id: refresh diff --git a/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-lock/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-lock/src/init.lua b/drivers/SmartThings/zwave-lock/src/init.lua index b83b196256..b49489a008 100644 --- a/drivers/SmartThings/zwave-lock/src/init.lua +++ b/drivers/SmartThings/zwave-lock/src/init.lua @@ -1,194 +1,114 @@ --- Copyright 2022 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. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 -local capabilities = require "st.capabilities" ---- @type st.zwave.CommandClass -local cc = require "st.zwave.CommandClass" --- @type st.zwave.Driver local ZwaveDriver = require "st.zwave.driver" --- @type st.zwave.defaults local defaults = require "st.zwave.defaults" ---- @type st.zwave.CommandClass.DoorLock -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.UserCode local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) ---- @type st.zwave.CommandClass.Battery -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.CommandClass.Time -local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) -local constants = require "st.zwave.constants" -local utils = require "st.utils" -local json = require "st.json" +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local SCAN_CODES_CHECK_INTERVAL = 30 -local MIGRATION_COMPLETE = "migrationComplete" -local MIGRATION_RELOAD_SKIPPED = "migrationReloadSkipped" +local capabilities = require "st.capabilities" -local function periodic_codes_state_verification(driver, device) - local scan_codes_state = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.scanCodes.NAME) - if scan_codes_state == "Scanning" then - driver:inject_capability_command(device, - { capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} - } - ) - device.thread:call_with_delay( - SCAN_CODES_CHECK_INTERVAL, - function(d) - periodic_codes_state_verification(driver, device) - end - ) - end -end +local consts = require "lock_utils.constants" +local table_utils = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" +local capability_handlers = require "lock_handlers.capabilities" -local function populate_state_from_data(device) - if device.data.lockCodes ~= nil and device:get_field(MIGRATION_COMPLETE) ~= true then - -- build the lockCodes table - local lockCodes = {} - local lc_data = json.decode(device.data.lockCodes) - for k, v in pairs(lc_data) do - lockCodes[k] = v - end - -- Populate the devices `lockCodes` field - device:set_field(constants.LOCK_CODES, utils.deep_copy(lockCodes), { persist = true }) - -- Populate the devices state history cache - device.state_cache["main"] = device.state_cache["main"] or {} - device.state_cache["main"][capabilities.lockCodes.ID] = device.state_cache["main"][capabilities.lockCodes.ID] or {} - device.state_cache["main"][capabilities.lockCodes.ID][capabilities.lockCodes.lockCodes.NAME] = {value = json.encode(utils.deep_copy(lockCodes))} - device:set_field(MIGRATION_COMPLETE, true, { persist = true }) - end -end +local LockLifecycle = {} ---- Builds up initial state for the device ---- ---- @param self st.zwave.Driver ---- @param device st.zwave.Device -local function added_handler(self, device) - populate_state_from_data(device) - if device.data.lockCodes == nil or device:get_field(MIGRATION_RELOAD_SKIPPED) == true then - if (device:supports_capability(capabilities.lockCodes)) then - self:inject_capability_command(device, - { capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.reloadAllCodes.NAME, - args = {} }) - device.thread:call_with_delay( - SCAN_CODES_CHECK_INTERVAL, - function(d) - periodic_codes_state_verification(self, device) - end - ) - end - else - device:set_field(MIGRATION_RELOAD_SKIPPED, true, { persist = true }) - end - device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) - if (device:supports_capability(capabilities.tamperAlert)) then +function LockLifecycle.device_added(driver, device) + if device:supports_capability(capabilities.tamperAlert) then device:emit_event(capabilities.tamperAlert.tamper.clear()) end + -- set initial state + driver:inject_capability_command(device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {} + }) end -local init_handler = function(driver, device, event) - populate_state_from_data(device) - -- temp fix before this can be changed from being persisted in memory - device:set_field(constants.CODE_STATE, nil, { persist = true }) -end +function LockLifecycle.init(driver, device) + -- Restore users/credentials capability state from the persistent store in case + -- the capability state cache was wiped since the last driver run. + table_utils.restore_from_persistent_store(device) -local do_refresh = function(self, device) - device:send(DoorLock:OperationGet({})) - device:send(Battery:Get({})) -end + local lock_pins_supported_by_profile = device:supports_capability(capabilities.lockCodes) + if lock_pins_supported_by_profile and device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) == true then + -- ensure lockCodes capability state is reflected correctly for already migrated devices + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.supportedCredentials({ consts.CRED_TYPE_PIN }, { visibility = { displayed = false } })) + end ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @param cmd table -local function update_codes(driver, device, cmd) - local delay = 0 - -- args.codes is json - for name, code in pairs(cmd.args.codes) do - -- these seem to come in the format "code[slot#]: code" - local code_slot = tonumber(string.gsub(name, "code", ""), 10) - if (code_slot ~= nil) then - if (code ~= nil and (code ~= "0" and code ~= "")) then - -- code changed - device.thread:call_with_delay(delay, function () - device:send(UserCode:Set({ - user_identifier = code_slot, - user_code = code, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) - end) - delay = delay + 2.2 - else - -- code deleted - device.thread:call_with_delay(delay, function () - device:send(UserCode:Set({user_identifier = code_slot, user_id_status = UserCode.user_id_status.AVAILABLE})) - end) - delay = delay + 2.2 - device.thread:call_with_delay(delay, function () - device:send(UserCode:Get({user_identifier = code_slot})) - end) - delay = delay + 2.2 - end - end + if device:supports_capability(capabilities.tamperAlert) then + -- ensure our user/credential state is accurate to the current device state + device:emit_event(capabilities.tamperAlert.tamper.clear()) end end -local function time_get_handler(driver, device, cmd) - local time = os.date("*t") - device:send_to_component( - Time:Report({ - hour_local_time = time.hour, - minute_local_time = time.min, - second_local_time = time.sec - }), - device:endpoint_to_component(cmd.src_channel) - ) +function LockLifecycle.driver_switched(driver, device) + if device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.migrated.NAME) == true then + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + end + device:try_update_metadata({ provisioning_state = "PROVISIONED" }) end local driver_template = { - supported_capabilities = { - capabilities.lock, - capabilities.lockCodes, - capabilities.battery, - capabilities.tamperAlert - }, lifecycle_handlers = { - added = added_handler, - init = init_handler, + added = LockLifecycle.device_added, + driver_switched = LockLifecycle.driver_switched, + init = LockLifecycle.init, + }, + zwave_handlers = { + [cc.TIME] = { + [0x01] = zwave_handlers.time_get_handler, -- used by DanaLock + [0x03] = zwave_handlers.date_get_handler + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = zwave_handlers.notification_report + }, + [cc.USER_CODE] = { + [UserCode.REPORT] = zwave_handlers.user_code_report, + [UserCode.USERS_NUMBER_REPORT] = zwave_handlers.users_number_report, + } }, capability_handlers = { - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes + [capabilities.lock.ID] = { + [capabilities.lock.commands.lock.NAME] = capability_handlers.lock, + [capabilities.lock.commands.unlock.NAME] = capability_handlers.unlock, + }, + [capabilities.lockUsers.ID] = { + [capabilities.lockUsers.commands.addUser.NAME] = capability_handlers.add_user, + [capabilities.lockUsers.commands.updateUser.NAME] = capability_handlers.update_user, + [capabilities.lockUsers.commands.deleteUser.NAME] = capability_handlers.delete_user, + [capabilities.lockUsers.commands.deleteAllUsers.NAME] = capability_handlers.delete_all_users, + }, + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = capability_handlers.add_credential, + [capabilities.lockCredentials.commands.updateCredential.NAME] = capability_handlers.update_credential, + [capabilities.lockCredentials.commands.deleteCredential.NAME] = capability_handlers.delete_credential, + [capabilities.lockCredentials.commands.deleteAllCredentials.NAME] = capability_handlers.delete_all_credentials, }, [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = do_refresh - } + [capabilities.refresh.commands.refresh.NAME] = capability_handlers.refresh, + }, }, - zwave_handlers = { - [cc.TIME] = { - [Time.GET] = time_get_handler -- used by DanaLock - } + supported_capabilities = { + capabilities.lock, + capabilities.lockCodes, + capabilities.lockUsers, + capabilities.lockCredentials, + capabilities.battery, + capabilities.tamperAlert, }, - sub_drivers = { - require("zwave-alarm-v1-lock"), - require("schlage-lock"), - require("samsung-lock"), - require("keywe-lock"), - require("apiv6_bugfix"), - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua new file mode 100644 index 0000000000..fda854593d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/keywe-lock/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_keywe_lock(opts, self, device, cmd, ...) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated then + local KEYWE_MFR = 0x037B + if device.zwave_manufacturer_id == KEYWE_MFR then + return true, require("legacy-handlers.keywe-lock") + end + end + return false +end + +return can_handle_keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua index d39aa45d1c..455d301473 100644 --- a/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/keywe-lock/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -19,17 +9,11 @@ local Association = (require "st.zwave.CommandClass.Association")({version=2}) local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local access_control_event = Notification.event.access_control -local LockDefaults = require "st.zwave.defaults.lock" -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" local TamperDefaults = require "st.zwave.defaults.tamperAlert" +local zwave_handlers = require "lock_handlers.zwave_responses" -local KEYWE_MFR = 0x037B local TAMPER_CLEAR_DELAY = 10 -local function can_handle_keywe_lock(opts, self, device, cmd, ...) - return device.zwave_manufacturer_id == KEYWE_MFR -end - local function clear_tamper_if_needed(device) local current_tamper_state = device:get_latest_state("main", capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME) if current_tamper_state == "detected" then @@ -54,8 +38,9 @@ local function notification_report_handler(self, device, cmd) if event ~= nil then device:emit_event(event) else - LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + zwave_handlers.door_operation_event_handler(self, device, cmd) + zwave_handlers.code_event_handler(self, device, cmd) + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) device.thread:call_with_delay( TAMPER_CLEAR_DELAY, @@ -80,7 +65,7 @@ local keywe_lock = { doConfigure = do_configure }, NAME = "Keywe Lock", - can_handle = can_handle_keywe_lock, + can_handle = require("keywe-lock.can_handle"), } return keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-lock/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua new file mode 100644 index 0000000000..78757208a4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if not slga_migrated then + local subdriver = require("legacy-handlers") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua new file mode 100644 index 0000000000..3bcb8adfe4 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/init.lua @@ -0,0 +1,133 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local init_handler = function(driver, device, event) + local constants = require "st.zwave.constants" + -- temp fix before this can be changed from being persisted in memory + device:set_field(constants.CODE_STATE, nil, { persist = true }) +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd table +local function update_codes(driver, device, cmd) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + local delay = 0 + -- args.codes is json + for name, code in pairs(cmd.args.codes) do + -- these seem to come in the format "code[slot#]: code" + local code_slot = tonumber(string.gsub(name, "code", ""), 10) + if (code_slot ~= nil) then + if (code ~= nil and (code ~= "0" and code ~= "")) then + -- code changed + device.thread:call_with_delay(delay, function () + device:send(UserCode:Set({ + user_identifier = code_slot, + user_code = code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS})) + end) + delay = delay + 2.2 + else + -- code deleted + device.thread:call_with_delay(delay, function () + device:send(UserCode:Set({user_identifier = code_slot, user_id_status = UserCode.user_id_status.AVAILABLE})) + end) + delay = delay + 2.2 + device.thread:call_with_delay(delay, function () + device:send(UserCode:Get({user_identifier = code_slot})) + end) + delay = delay + 2.2 + end + end + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd table +local function migrate(driver, device, cmd) + local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + local get_lock_codes = LockCodesDefaults.get_lock_codes + local lock_users = {} + local lock_credentials = {} + local lock_codes = get_lock_codes(device) + local ordered_codes = {} + + for code in pairs(lock_codes) do + table.insert(ordered_codes, code) + end + + table.sort(ordered_codes) + for index = 1, #ordered_codes do + local code_slot, code_name = ordered_codes[index], lock_codes[ ordered_codes[index] ] + table.insert(lock_users, {userIndex = index, userType = "guest", userName = code_name}) + table.insert(lock_credentials, {userIndex = index, credentialIndex = tonumber(code_slot), credentialType = "pin"}) + end + device:emit_event(capabilities.lockCredentials.credentials(lock_credentials, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.users(lock_users, { visibility = { displayed = false } })) + + local code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if code_length then + device:emit_event(capabilities.lockCredentials.minPinCodeLen(code_length, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(code_length, { visibility = { displayed = false } })) + end + + local max_codes = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.maxCodes.NAME) + if max_codes then + device:emit_event(capabilities.lockCredentials.pinUsersSupported(max_codes, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockUsers.totalUsersSupported(max_codes, { visibility = { displayed = false } })) + else + -- if we don't have a code length, request it from the device + device:send(UserCode:UsersNumberGet({})) + end + + device:emit_event(capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } })) + device:emit_event(capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + local consts = require("lock_utils.constants") + + device:set_field(consts.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) -- persist the migrated state to the datastore +end + +local legacy_capabilities = { + supported_capabilities = { + capabilities.lock, + capabilities.lockCodes, + capabilities.battery, + capabilities.tamperAlert + }, + lifecycle_handlers = { + init = init_handler, + }, + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.updateCodes.NAME] = update_codes, + [capabilities.lockCodes.commands.migrate.NAME] = migrate + }, + }, + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = function(driver, device, cmd) + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + local TamperDefaults = require "st.zwave.defaults.tamperAlert" + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) + end + }, + [cc.USER_CODE] = { + [UserCode.REPORT] = LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT], + [UserCode.USERS_NUMBER_REPORT] = LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.USERS_NUMBER_REPORT], + } + }, + sub_drivers = require("legacy-handlers.sub_drivers"), + can_handle = require("legacy-handlers.can_handle"), + NAME = "legacy-handlers" +} + +return legacy_capabilities diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua new file mode 100644 index 0000000000..0970a9c9ed --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_keywe_lock(opts, self, device, cmd, ...) + local KEYWE_MFR = 0x037B + if device.zwave_manufacturer_id == KEYWE_MFR then + return true, require("legacy-handlers.keywe-lock") + end + return false +end + +return can_handle_keywe_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua new file mode 100644 index 0000000000..b40e6658b1 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/keywe-lock/init.lua @@ -0,0 +1,71 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Association = (require "st.zwave.CommandClass.Association")({version=2}) +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local TamperDefaults = require "st.zwave.defaults.tamperAlert" + +local TAMPER_CLEAR_DELAY = 10 + +local function clear_tamper_if_needed(device) + local current_tamper_state = device:get_latest_state("main", capabilities.tamperAlert.ID, capabilities.tamperAlert.tamper.NAME) + if current_tamper_state == "detected" then + device:emit_event(capabilities.tamperAlert.tamper.clear()) + end +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_OPEN then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.WINDOW_DOOR_HANDLE_IS_CLOSED then + event = capabilities.lock.lock.locked() + end + if event ~= nil then + event["data"] = {method = "manual"} + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + device.thread:call_with_delay( + TAMPER_CLEAR_DELAY, + function(d) + clear_tamper_if_needed(device) + end + ) + end +end + +local function do_configure(self, device) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local keywe_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Keywe Lock", + can_handle = require("keywe-lock.can_handle"), +} + +return keywe_lock \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua new file mode 100644 index 0000000000..dc97d03ece --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local SAMSUNG_MFR = 0x022E + if device.zwave_manufacturer_id == SAMSUNG_MFR then + local subdriver = require("legacy-handlers.samsung-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua new file mode 100644 index 0000000000..6e28668b0b --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/samsung-lock/init.lua @@ -0,0 +1,96 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" + +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local access_control_event = Notification.event.access_control + +local json = require "dkjson" +local constants = require "st.zwave.constants" + +local LockDefaults = require "st.zwave.defaults.lock" +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local get_lock_codes = LockCodesDefaults.get_lock_codes +local clear_code_state = LockCodesDefaults.clear_code_state +local code_deleted = LockCodesDefaults.code_deleted + + +local function get_ongoing_code_set(device) + local code_id + local code_state = device:get_field(constants.CODE_STATE) + if code_state ~= nil then + for key, state in pairs(code_state) do + if state ~= nil then + code_id = key:match("setName(%d)") + end + end + end + return code_id +end + +local function notification_report_handler(self, device, cmd) + local event + if (cmd.args.notification_type == Notification.notification_type.ACCESS_CONTROL) then + local event_code = cmd.args.event + if event_code == access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION then + event = capabilities.lock.lock.unlocked() + elseif event_code == access_control_event.NEW_USER_CODE_ADDED then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + device:send(UserCode:Get({user_identifier = code_id})) + return + end + elseif event_code == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then + local code_id = get_ongoing_code_set(device) + if code_id ~= nil then + event = capabilities.lockCodes.codeChanged(code_id .. " failed", { state_change = true }) + clear_code_state(device, code_id) + end + elseif event_code == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then + -- Update Master Code in the same way as in defaults... + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + -- ...and delete rest of them, as lock does + local lock_codes = get_lock_codes(device) + for code_id, _ in pairs(lock_codes) do + if code_id ~= "0" then + code_deleted(device, code_id) + end + end + event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + end + end + + if event ~= nil then + device:emit_event(event) + else + LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + end +end + +-- Used doConfigure instead of added to not overwrite parent driver's added_handler +local function do_configure(self, device) + -- taken directly from DTH + -- Samsung locks won't allow you to enter the pairing menu when locked, so it must be unlocked + device:emit_event(capabilities.lock.lock.unlocked()) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } })) +end + +local samsung_lock = { + zwave_handlers = { + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure + }, + NAME = "Samsung Lock", + can_handle = require("legacy-handlers.samsung-lock.can_handle"), +} + +return samsung_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua new file mode 100644 index 0000000000..e45491efe2 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local SCHLAGE_MFR = 0x003B + if device.zwave_manufacturer_id == SCHLAGE_MFR then + local subdriver = require("legacy-handlers.schlage-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua new file mode 100644 index 0000000000..a6604b01f5 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/schlage-lock/init.lua @@ -0,0 +1,178 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +local cc = require "st.zwave.CommandClass" +local constants = require "st.zwave.constants" +local json = require "dkjson" + +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local user_id_status = UserCode.user_id_status +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +local access_control_event = Notification.event.access_control +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=2}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) + +local LockCodesDefaults = require "st.zwave.defaults.lockCodes" + +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local DEFAULT_COMMANDS_DELAY = 4.2 -- seconds + +local function set_code_length(self, device, cmd) + local length = cmd.args.length + if length >= 4 and length <= 8 then + device:send(Configuration:Set({ + parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, + configuration_value = length, + size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size + })) + end +end + +local function reload_all_codes(self, device, cmd) + LockCodesDefaults.capability_handlers[capabilities.lockCodes.commands.reloadAllCodes](self, device, cmd) + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + end +end + +local function set_code(self, device, cmd) + if (cmd.args.codePIN == "") then + self:inject_capability_command(device, { + capability = capabilities.lockCodes.ID, + command = capabilities.lockCodes.commands.nameSlot.NAME, + args = {cmd.args.codeSlot, cmd.args.codeName}, + }) + else + -- copied from defaults with additional check for Schlage's configuration + if (cmd.args.codeName ~= nil and cmd.args.codeName ~= "") then + if (device:get_field(constants.CODE_STATE) == nil) then device:set_field(constants.CODE_STATE, { persist = true }) end + local code_state = device:get_field(constants.CODE_STATE) + code_state["setName"..cmd.args.codeSlot] = cmd.args.codeName + device:set_field(constants.CODE_STATE, code_state, { persist = true }) + end + local send_set_user_code = function () + device:send(UserCode:Set({ + user_identifier = cmd.args.codeSlot, + user_code = cmd.args.codePIN, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) + ) + end + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, send_set_user_code) + else + send_set_user_code() + end + end +end + +local function do_configure(self, device) + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function basic_set_handler(self, device, cmd) + device:emit_event(cmd.args.value == 0 and capabilities.lock.lock.unlocked() or capabilities.lock.lock.locked()) + device:send(Association:Remove({grouping_identifier = 1, node_ids = {self.environment_info.hub_zwave_id}})) +end + +local function configuration_report(self, device, cmd) + local parameter_number = cmd.args.parameter_number + if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then + local reported_code_length = cmd.args.configuration_value + local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + if current_code_length ~= nil and current_code_length ~= reported_code_length then + local all_codes_deleted_mocked_command = Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.ALL_USER_CODES_DELETED + }) + LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, all_codes_deleted_mocked_command) + end + device:emit_event(capabilities.lockCodes.codeLength(reported_code_length)) + end +end + +local function is_user_code_report_mfr_specific(device, cmd) + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local code_id = cmd.args.user_identifier + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_state = device:get_field(constants.CODE_STATE) + return user_code == "**********" or user_code == nil or (code_state ~= nil and code_state["setName"..cmd.args.user_identifier] ~= nil) + else + return (code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE) or + reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE + end +end + +local function user_code_report_handler(self, device, cmd) + local code_id = cmd.args.user_identifier + if is_user_code_report_mfr_specific(device, cmd) then + local reported_user_id_status = cmd.args.user_id_status + local user_code = cmd.args.user_code + local event + + if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 + (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then + local code_name = LockCodesDefaults.get_code_name(device, code_id) + local change_type = LockCodesDefaults.get_change_type(device, code_id) + event = capabilities.lockCodes.codeChanged(code_id..""..change_type, { state_change = true }) + event.data = {codeName = code_name} + if code_id ~= 0 then -- ~= MASTER_CODE + LockCodesDefaults.code_set_event(device, code_id, code_name) + end + elseif code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE then + local lock_codes = LockCodesDefaults.get_lock_codes(device) + for _code_id, _ in pairs(lock_codes) do + LockCodesDefaults.code_deleted(device, _code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(LockCodesDefaults.get_lock_codes(device)), { visibility = { displayed = false } })) + else -- user_id_status.STATUS_NOT_AVAILABLE + event = capabilities.lockCodes.codeChanged(code_id.." failed", { state_change = true }) + end + + if event ~= nil then + device:emit_event(event) + end + LockCodesDefaults.clear_code_state(device, code_id) + LockCodesDefaults.verify_set_code_completion(device, cmd, code_id) + else + LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](self, device, cmd) + end +end + +local schlage_lock = { + capability_handlers = { + [capabilities.lockCodes.ID] = { + [capabilities.lockCodes.commands.setCodeLength.NAME] = set_code_length, + [capabilities.lockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, + [capabilities.lockCodes.commands.setCode.NAME] = set_code + } + }, + zwave_handlers = { + [cc.USER_CODE] = { + [UserCode.REPORT] = user_code_report_handler + }, + [cc.CONFIGURATION] = { + [Configuration.REPORT] = configuration_report + }, + [cc.BASIC] = { + [Basic.SET] = basic_set_handler + } + }, + lifecycle_handlers = { + doConfigure = do_configure, + }, + NAME = "Schlage Lock", + can_handle = require("legacy-handlers.schlage-lock.can_handle"), +} + +return schlage_lock diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua new file mode 100644 index 0000000000..4c94ca5559 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("legacy-handlers.zwave-alarm-v1-lock"), + lazy_load_if_possible("legacy-handlers.schlage-lock"), + lazy_load_if_possible("legacy-handlers.samsung-lock"), + lazy_load_if_possible("legacy-handlers.keywe-lock"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua new file mode 100644 index 0000000000..324f83e6f9 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/can_handle.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 then + local subdriver = require("legacy-handlers.zwave-alarm-v1-lock") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua new file mode 100644 index 0000000000..5865091852 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/legacy-handlers/zwave-alarm-v1-lock/init.lua @@ -0,0 +1,146 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Alarm +local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.defaults.lockCodes +local lock_code_defaults = require "st.zwave.defaults.lockCodes" +local json = require "dkjson" + +local METHOD = { + KEYPAD = "keypad", + MANUAL = "manual", + COMMAND = "command", + AUTO = "auto" +} + +--- Default handler for alarm command class reports, these were largely OEM-defined +--- +--- This converts alarm V1 reports to correct lock events +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd st.zwave.CommandClass.Alarm.Report +local function alarm_report_handler(driver, device, cmd) + local alarm_type = cmd.args.alarm_type + local event = nil + local lock_codes = lock_code_defaults.get_lock_codes(device) + local code_id = nil + if (cmd.args.alarm_level ~= nil) then + code_id = tostring(cmd.args.alarm_level) + end + if (alarm_type == 9 or alarm_type == 17) then + event = capabilities.lock.lock.unknown() + elseif (alarm_type == 16 or alarm_type == 19) then + event = capabilities.lock.lock.unlocked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 18) then + event = capabilities.lock.lock.locked() + if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + end + elseif (alarm_type == 21) then + event = capabilities.lock.lock.locked() + if (cmd.args.alarm_level == 2) then + event["data"] = {method = METHOD.MANUAL} + else + event["data"] = {method = METHOD.KEYPAD} + end + elseif (alarm_type == 22) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.MANUAL} + elseif (alarm_type == 23) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 24) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 25) then + event = capabilities.lock.lock.unlocked() + event["data"] = {method = METHOD.COMMAND} + elseif (alarm_type == 26) then + event = capabilities.lock.lock.unknown() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 27) then + event = capabilities.lock.lock.locked() + event["data"] = {method = METHOD.AUTO} + elseif (alarm_type == 32) then + -- all user codes deleted + for code_id, _ in pairs(lock_codes) do + lock_code_defaults.code_deleted(device, code_id) + end + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + elseif (alarm_type == 33) then + -- user code deleted + if (code_id ~= nil) then + lock_code_defaults.clear_code_state(device, code_id) + if (lock_codes[code_id] ~= nil) then + lock_code_defaults.code_deleted(device, code_id) + device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + end + end + elseif (alarm_type == 13 or alarm_type == 112) then + -- user code changed/set + if (code_id ~= nil) then + local code_name = lock_code_defaults.get_code_name(device, code_id) + local change_type = lock_code_defaults.get_change_type(device, code_id) + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. change_type, { state_change = true }) + code_changed_event["data"] = { codeName = code_name} + lock_code_defaults.code_set_event(device, code_id, code_name) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 34 or alarm_type == 113) then + -- duplicate lock code + if (code_id ~= nil) then + local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. lock_code_defaults.CHANGE_TYPE.FAILED, { state_change = true }) + lock_code_defaults.clear_code_state(device, code_id) + device:emit_event(code_changed_event) + end + elseif (alarm_type == 130) then + -- batteries replaced + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 161) then + -- tamper alarm + event = capabilities.tamperAlert.tamper.detected() + elseif (alarm_type == 167) then + -- low battery + if (device:is_cc_supported(cc.BATTERY)) then + driver:call_with_delay(10, function(d) device:send(Battery:Get({})) end ) + end + elseif (alarm_type == 168) then + -- critical battery + event = capabilities.battery.battery(1) + elseif (alarm_type == 169) then + -- battery too low to operate + event = capabilities.battery.battery(0) + end + + if (event ~= nil) then + device:emit_event(event) + end +end + +local zwave_lock = { + zwave_handlers = { + [cc.ALARM] = { + [Alarm.REPORT] = alarm_report_handler + } + }, + NAME = "Z-Wave lock alarm V1", + can_handle = require("legacy-handlers.zwave-alarm-v1-lock.can_handle") +} + +return zwave_lock diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua new file mode 100644 index 0000000000..13d45bc4dd --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/capabilities.lua @@ -0,0 +1,252 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" +local consts = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + + +local CapabilityHandlers = {} + + +-- [[ LOCK USERS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + -- Find the smallest positive userIndex not already in the table + local next_available_index = tables.next_index(device, "users") + local status = tables.add_entry(device, "users", { + userIndex = next_available_index, + userName = command.args.userName, + userType = command.args.userType, + }) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = next_available_index } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.ADD, status, additional_info) +end + +function CapabilityHandlers.update_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + local result_status + if not tables.find_entry(device, "users", command.args.userIndex) then + result_status = tables.add_entry(device, "users", { + userIndex = command.args.userIndex, + userName = command.args.userName, + userType = command.args.userType, + }) + else + result_status = tables.update_entry(device, "users", + command.args.userIndex, + { userName = command.args.userName, userType = command.args.userType } + ) + end + + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.UPDATE, result_status, additional_info) +end + +function CapabilityHandlers.delete_user(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, consts.COMMAND_RESULT.BUSY) + return + end + + local associated_credentials = tables.find_all_entries_by(device, "credentials", "userIndex", command.args.userIndex) + for _, associated_credential in ipairs(associated_credentials) do + -- Set busy state with the full user+credential context BEFORE injecting. + -- Injected capability commands are schema-validated, so extra args like userIndex + -- would be stripped. By setting device fields here we preserve the full context. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE, { + userIndex = command.args.userIndex, + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + }) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteCredential.NAME, + args = { + credentialIndex = associated_credential.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + } + }) + end + if #associated_credentials == 0 then + -- No associated credentials: delete the user entry directly and report the result + local status = tables.delete_entry(device, "users", command.args.userIndex) + local additional_info = status == consts.COMMAND_RESULT.SUCCESS and { userIndex = command.args.userIndex } + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, status, additional_info) + end +end + +function CapabilityHandlers.delete_all_users(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + return + end + -- Set busy state with DELETE_ALL context BEFORE injecting so the response handler + -- knows to clear both tables and emit results for both capabilities. + lock_utils.set_busy_state(device, consts.LOCK_USERS.DELETE_ALL, {}) + driver:inject_capability_command(device, { + capability = capabilities.lockCredentials.ID, + command = capabilities.lockCredentials.commands.deleteAllCredentials.NAME, + args = { credentialType = consts.CRED_TYPE_PIN } + }) +end + + +-- [[ LOCK CREDENTIALS CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.add_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.BUSY) + return + end + + -- A userIndex of 0 means "auto-assign the next available slot" + local user_index = command.args.userIndex == 0 and tables.next_index(device, "users") or command.args.userIndex + local cred_index = command.args.userIndex == 0 and tables.next_index(device, "credentials") or command.args.userIndex + + if #(tables.get_state(device, "credentials") or {}) >= tables.get_max_entries(device, "credentials") then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.RESOURCE_EXHAUSTED) + return + end + + -- Set busy state and attempt to set the PIN on-device. + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.ADD, { + userIndex = user_index, + credentialIndex = cred_index, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(UserCode:Set({ + user_identifier = cred_index, + user_code = command.args.credentialData, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })) +end + +function CapabilityHandlers.update_credential(driver, device, command) + if lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.BUSY) + return + end + + if not tables.find_entry(device, "credentials", command.args.credentialIndex) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.FAILURE) + return + end + + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.UPDATE, { + userIndex = command.args.userIndex, + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + credentialName = command.args.credentialName, + }) + device:send(UserCode:Set({ + user_identifier = command.args.credentialIndex, + user_code = command.args.credentialData, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS + })) +end + +function CapabilityHandlers.delete_credential(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + if cmd_in_progress == consts.LOCK_USERS.DELETE then + -- Injected by deleteUser; busy state was already set with the full LOCK_USERS.DELETE context. + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + device:send(UserCode:Set({ + user_identifier = credential_args.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.BUSY) + else + -- Standalone deleteCredential: look up the credential to obtain its associated userIndex. + local found_cred = tables.find_entry(device, "credentials", command.args.credentialIndex) + if not found_cred then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, consts.COMMAND_RESULT.FAILURE) + return + end + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE, { + credentialIndex = command.args.credentialIndex, + credentialType = command.args.credentialType, + userIndex = found_cred.userIndex, + }) + device:send(UserCode:Set({ + user_identifier = command.args.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + end +end + +function CapabilityHandlers.delete_all_credentials(driver, device, command) + local cmd_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + local send_clear_commands = function() + local credentials = tables.get_state(device, "credentials") or {} + local delay = 0 + for _, credential in ipairs(credentials) do + device.thread:call_with_delay(delay, function() + device:send(UserCode:Set({ + user_identifier = credential.credentialIndex, + user_id_status = UserCode.user_id_status.AVAILABLE + })) + end) + delay = delay + 2 + end + -- Delayed cleanup: ensure all tables are cleared after individual responses complete + device.thread:call_with_delay(delay + 4, function() + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then + tables.delete_all_entries(device, "users") + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) + end + tables.delete_all_entries(device, "credentials") + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.SUCCESS) + lock_utils.clear_busy_state(device) + end) + end + + if cmd_in_progress == consts.LOCK_USERS.DELETE_ALL then + -- Injected by deleteAllUsers; busy state was already set with LOCK_USERS.DELETE_ALL context. + send_clear_commands() + elseif lock_utils.is_device_busy(device) then + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE_ALL, consts.COMMAND_RESULT.BUSY) + else + lock_utils.set_busy_state(device, consts.LOCK_CREDENTIALS.DELETE_ALL, command.args) + send_clear_commands() + end +end + + +-- [[ REFRESH CAPABILITY COMMANDS ]] -- + +function CapabilityHandlers.refresh(driver, device, cmd) + device:send(DoorLock:OperationGet({})) + device:send(Battery:Get({})) + + if device:supports_capability(capabilities.lockCredentials) then + -- If we are missing the cached values for these attributes, read them so we can properly manage them locally + if (device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.pinUsersSupported.NAME) == nil) or + (device:get_latest_state("main", capabilities.lockUsers.ID, capabilities.lockUsers.totalUsersSupported.NAME) == nil) then + device:send(UserCode:UsersNumberGet({})) + end + end +end + +return CapabilityHandlers diff --git a/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua new file mode 100644 index 0000000000..7822ab2bfe --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_handlers/zwave_responses.lua @@ -0,0 +1,256 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local socket = require "cosock.socket" + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) +local TamperDefaults = require "st.zwave.defaults.tamperAlert" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" + + +local ZwaveHandlers = {} + + +-- [[ USER CODE COMMAND CLASS ]] + +function ZwaveHandlers.user_code_report(driver, device, cmd) + -- zw report values + local credential_index = cmd.args.user_identifier + local user_id_status = cmd.args.user_id_status + + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + + -- Determine boolean states of user code slot + local user_code_occupied = user_id_status == UserCode.user_id_status.ENABLED_GRANT_ACCESS or + (user_id_status == UserCode.user_id_status.STATUS_NOT_AVAILABLE and cmd.args.user_code) + local user_code_available = user_id_status == UserCode.user_id_status.AVAILABLE + + if command_in_progress == consts.SYNC.CODES_FROM_LOCK then + if user_code_occupied then + -- If an entry already exists at this index, this will be a no-op. + tables.add_entry(device, "users", { + userIndex = credential_index, + userName = "Guest " .. credential_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = credential_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. credential_index, + }) + -- reset the consecutive unoccupied codes counter since we found an occupied code + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, 0) + elseif user_code_available then + local consecutive_unoccupied_codes = (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) + 1 + device:set_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES, consecutive_unoccupied_codes) + end + + -- Sync: continue to next code or finish + if credential_index >= tables.get_max_entries(device, "credentials") or + (device:get_field(consts.SYNC.CONSECUTIVE_UNOCCUPIED_CODES) or 0) > 5 then + -- stop if we hit the max number of codes supported by the lock, or if we have received 5 consecutive unoccupied codes. + device:set_field(consts.SYNC.CODE_INDEX, nil) + lock_utils.clear_busy_state(device) + else + local synced_code_index = device:get_field(consts.SYNC.CODE_INDEX) + 1 + device:set_field(consts.SYNC.CODE_INDEX, synced_code_index) + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK, { checkingCode = synced_code_index }) + device:send(UserCode:Get({user_identifier = synced_code_index})) + end + return + elseif user_code_occupied then + -- Code slot is now occupied: add or update credential and associated user + lock_utils.set_credential_report_helper(device, credential_index) + elseif user_code_available then + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.FAILURE) + lock_utils.clear_busy_state(device) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE or tables.find_entry(device, "credentials", credential_index) then + lock_utils.delete_credential_report_helper(device, credential_index) + end + end +end + +function ZwaveHandlers.users_number_report(driver, device, cmd) + device:emit_event(capabilities.lockUsers.totalUsersSupported( + cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) + device:emit_event(capabilities.lockCredentials.pinUsersSupported( + cmd.args.supported_users, { state_change = true, visibility = { displayed = false } })) +end + + +-- [[ NOTIFICATION COMMAND CLASS ]] -- + +function ZwaveHandlers.user_code_event_handler(driver, device, cmd) + if cmd.args.notification_type ~= Notification.notification_type.ACCESS_CONTROL then + return + end + -- zw event values + local event = cmd.args.event + local credential_index = tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level)) + -- cached value from capability event, if applicable + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + -- This type includes many lock-related events, way too many to list, + -- including user code changes and door operations + local access_control_event = Notification.event.access_control + + if event == access_control_event.ALL_USER_CODES_DELETED then + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") + + elseif event == access_control_event.SINGLE_USER_CODE_DELETED then + lock_utils.delete_credential_report_helper(device, credential_index) + + elseif event == access_control_event.NEW_USER_CODE_ADDED then + lock_utils.set_credential_report_helper(device, credential_index) + + elseif event == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then + -- aka "master code" changed + if command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.UPDATE, consts.COMMAND_RESULT.SUCCESS) + lock_utils.clear_busy_state(device) + end + + elseif event == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then + if command_in_progress then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + consts.LOCK_CREDENTIALS.ADD, consts.COMMAND_RESULT.DUPLICATE) + lock_utils.clear_busy_state(device) + end + end +end + +function ZwaveHandlers.door_operation_event_handler(driver, device, cmd) + if cmd.args.notification_type ~= Notification.notification_type.ACCESS_CONTROL then + return + end + -- zw event value + local event = cmd.args.event + -- This type includes many lock-related events, way too many to list, + -- including user code changes and door operations + local access_control_event = Notification.event.access_control + -- send lock, unlock, or unknown event based on the event coded + local capability_event + if not (event >= access_control_event.MANUAL_LOCK_OPERATION and event <= access_control_event.LOCK_JAMMED) then + return -- This is the subset of event kinds that we care about for door operation reporting + elseif ((event >= access_control_event.MANUAL_LOCK_OPERATION and + event <= access_control_event.KEYPAD_UNLOCK_OPERATION) or + event == access_control_event.AUTO_LOCK_LOCKED_OPERATION) then + -- even event codes are unlocks, odd event codes are locks + local events = {[0] = capabilities.lock.lock.unlocked(), [1] = capabilities.lock.lock.locked()} + capability_event = events[event & 1] + elseif (event >= access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION and + event <= access_control_event.LOCK_JAMMED) then + capability_event = capabilities.lock.lock.unknown() + else + return -- no lock event to send for this code + end + + local access_control_event_capability_map = { + [access_control_event.MANUAL_UNLOCK_OPERATION] = "manual", + [access_control_event.MANUAL_LOCK_OPERATION] = "manual", + [access_control_event.MANUAL_NOT_FULLY_LOCKED_OPERATION] = "manual", + [access_control_event.RF_LOCK_OPERATION] = "command", + [access_control_event.RF_UNLOCK_OPERATION] = "command", + [access_control_event.RF_NOT_FULLY_LOCKED_OPERATION] = "command", + [access_control_event.KEYPAD_LOCK_OPERATION] = "keypad", + [access_control_event.KEYPAD_UNLOCK_OPERATION] = "keypad", + [access_control_event.AUTO_LOCK_LOCKED_OPERATION] = "auto", + [access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION] = "auto" + } + + capability_event.data = {} + capability_event.data.method = access_control_event_capability_map[event] + + if (event == access_control_event.MANUAL_UNLOCK_OPERATION and cmd.args.event_parameter == 2) then + capability_event.data.method = "keypad" -- some locks can distinguish being manually locked via keypad + elseif (event == access_control_event.KEYPAD_LOCK_OPERATION or event == access_control_event.KEYPAD_UNLOCK_OPERATION) then + local code_id = tonumber(lock_utils.get_code_id_from_notification_event( + cmd.args.event_parameter, cmd.args.v1_alarm_level)) -- Look up stored lockUsers data if applicable + if device:supports_capability(capabilities.lockUsers) then + local credential = tables.find_entry(device, "credentials", code_id) + if credential then + local user = tables.find_entry(device, "users", credential.userIndex) + capability_event.data.userIndex = credential.userIndex + if user then + capability_event.data.userName = user.userName + capability_event.data.userType = user.userType + end + else + capability_event.data.userIndex = code_id + end + end + end + + -- Delay timer logic to handle duplicate lock state reports + if device:get_latest_state( + "main", + capabilities.lock.ID, + capabilities.lock.lock.ID) == capability_event.value.value then + local preceding_event_time = device:get_field(consts.DELAY_LOCK_EVENT) or 0 + local time_diff = socket.gettime() - preceding_event_time + if time_diff < consts.MAX_DELAY then + device:set_field(consts.DELAY_LOCK_EVENT, time_diff) + end + end + + local timer = device:get_field(consts.DELAY_LOCK_EVENT_TIMER) + if timer ~= nil then + device.thread:cancel_timer(timer) + device:set_field(consts.DELAY_LOCK_EVENT_TIMER, nil) + end + + device:emit_event(capability_event) +end + +function ZwaveHandlers.notification_report(driver, device, cmd) + ZwaveHandlers.user_code_event_handler(driver, device, cmd) + ZwaveHandlers.door_operation_event_handler(driver, device, cmd) + -- Tamper events handled by default tamper handler + TamperDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](driver, device, cmd) +end + + +-- [[ TIME COMMAND CLASS ]] -- + +function ZwaveHandlers.time_get_handler(driver, device, cmd) + local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) + local time = os.date("*t") + device:send_to_component( + Time:Report({ + hour_local_time = time.hour, + minute_local_time = time.min, + second_local_time = time.sec + }), + device:endpoint_to_component(cmd.src_channel) + ) +end + +function ZwaveHandlers.date_get_handler(driver, device, cmd) + local Time = (require "st.zwave.CommandClass.Time")({ version = 1 }) + local time = os.date("*t") + device:send_to_component( + Time:DateReport({ + year = time.year, + month = time.month, + day = time.day + }), + device:endpoint_to_component(cmd.src_channel) + ) +end + +return ZwaveHandlers diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua new file mode 100644 index 0000000000..f66c452beb --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/constants.lua @@ -0,0 +1,48 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lock_constants = {} + +lock_constants.DRIVER_STATE = { + BUSY = "busy", + COMMAND_IN_PROGRESS = "commandInProgress", + CREDENTIAL_ARGS_IN_USE = "currentCredential", + SLGA_MIGRATED = "slgaMigrated", +} + +lock_constants.SYNC = { + CODES_FROM_LOCK = "syncCodesFromLock", + CODE_INDEX = "syncCodeIndex", + CONSECUTIVE_UNOCCUPIED_CODES = "consecutiveUnoccupiedCodes", +} + +lock_constants.COMMAND_RESULT = { + SUCCESS = "success", + FAILURE = "failure", + DUPLICATE = "duplicate", + OCCUPIED = "occupied", + INVALID_COMMAND = "invalidCommand", + RESOURCE_EXHAUSTED = "resourceExhausted", + BUSY = "busy" +} + +lock_constants.LOCK_CREDENTIALS = { + ADD = "addCredential", + UPDATE = "updateCredential", + DELETE = "deleteCredential", + DELETE_ALL = "deleteAllCredentials" +} + +lock_constants.LOCK_USERS = { + ADD = "addUser", + UPDATE = "updateUser", + DELETE = "deleteUser", + DELETE_ALL = "deleteAllUsers" +} + +lock_constants.CRED_TYPE_PIN = "pin" +lock_constants.DELAY_LOCK_EVENT = "_delay_lock_event" +lock_constants.DELAY_LOCK_EVENT_TIMER = "_delay_lock_event_timer" +lock_constants.MAX_DELAY = 10 + +return lock_constants diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua new file mode 100644 index 0000000000..bfe3562344 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/tables.lua @@ -0,0 +1,268 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local COMMAND_RESULT = require "lock_utils.constants".COMMAND_RESULT + +local table_utils = {} + +-- DEFS describes how each capability-backed state table is structured: +-- +-- capability SmartThings capability (used for device support checks and to get latest state) +-- attribute Capability attribute function (used to emit state) +-- max_entries Attribute of the capability that defines the maximum number of entries allowed in the table +-- match_key Key used to identify entries in flat tables +-- required_keys Keys that must be non-nil when adding an entry +-- persistent_field device:set_field key used to back up the table across restarts +-- + +local DEFS = { + users = { + capability = capabilities.lockUsers, + attribute = capabilities.lockUsers.users, + max_entries = capabilities.lockUsers.totalUsersSupported, + match_key = "userIndex", + required_keys = {"userIndex", "userType"}, + persistent_field = "persistedUsers", + }, + credentials = { + capability = capabilities.lockCredentials, + attribute = capabilities.lockCredentials.credentials, + max_entries = capabilities.lockCredentials.pinUsersSupported, + match_key = "credentialIndex", + required_keys = {"userIndex", "credentialIndex", "credentialType"}, + persistent_field = "persistedCredentials", + } +} + +-- Resolve a table name to its definition. Logs an error and returns nil if unknown. +local function resolve_table_def(device, table_name) + local def = DEFS[table_name] + if not def then + device.log.error(string.format("table_helpers: unknown table %q", table_name)) + end + return def +end + +-- Validate that an entry table contains all required keys. +local function validate_entry(device, entry, required_keys) + for _, key in ipairs(required_keys or {}) do + if entry[key] == nil then + device.log.error(string.format("table_helpers: entry missing required key %q", key)) + return false + end + end + return true +end + +-- Write the current table contents to the device's persistent field store so that +-- the state survives driver restarts and can be restored if the capability state +-- cache is wiped. +local function persist_table(device, def, data) + device:set_field(def.persistent_field, st_utils.deep_copy(data), { persist = true }) +end + +-- Read the current state for a table and return a deep-copied array. +-- Accepts either a string table name ("users", "credentials") or a DEFS entry directly. +-- Returns nil (with a warning) if the capability is unsupported by the device. +-- When the capability state cache has been wiped (get_latest_state returns nil), +-- falls back to the persistent field store so that callers always receive the +-- last-known state rather than an empty table. +--- @return table[] | nil +function table_utils.get_state(device, name_or_def) + local def = type(name_or_def) == "string" and resolve_table_def(device, name_or_def) or name_or_def + if not def then return nil end + if not device:supports_capability(def.capability, "main") then + device.log.warn(string.format( + "table_helpers: device does not support capability %q", def.capability.ID + )) + return + end + local state = device:get_latest_state("main", def.capability.ID, def.attribute.NAME) + if state ~= nil then + return st_utils.deep_copy(state) + end + -- Capability state cache is absent (e.g. after a hub reboot); fall back to the + -- persistent store so that callers see the last-known table contents. + return st_utils.deep_copy(device:get_field(def.persistent_field) or {}) +end + +-- Find an entry in a named table where the match_key equals value. +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry(device, table_name, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[def.match_key] == value then return entry end + end + return nil +end + +-- Find an entry in a named table where entry[key] equals value (arbitrary key search). +-- Returns the matching entry, or nil if not found. +function table_utils.find_entry_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return nil end + local t = table_utils.get_state(device, def) + if not t then return nil end + for _, entry in ipairs(t) do + if entry[key] == value then return entry end + end + return nil +end + +-- Find all entries in a named table where entry[key] equals value (arbitrary key search). +-- Returns an array of matching entries, or an empty array if none found. +function table_utils.find_all_entries_by(device, table_name, key, value) + local def = resolve_table_def(device, table_name) + if not def then return {} end + local t = table_utils.get_state(device, def) + if not t then return {} end + local matches = {} + for _, entry in ipairs(t) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches +end + +-- Return the lowest positive integer not yet used as the match_key in the named table. +-- Used to auto-assign the next available slot for a new entry. +function table_utils.next_index(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return 1 end + local t = table_utils.get_state(device, def) or {} + local occupied = {} + for _, entry in ipairs(t) do occupied[entry[def.match_key]] = true end + local idx = 1 + while occupied[idx] do idx = idx + 1 end + return idx +end + +function table_utils.get_max_entries(device, table_name) + local def = resolve_table_def(device, table_name) + if not def then return end + return device:get_latest_state("main", def.capability.ID, def.max_entries.NAME, 20) -- arbitrary, default to 20 if the attribute is missing +end + +-- Add an entry to a named table. The entry must satisfy all required_keys for +-- that table. An entry whose match_key value already exists in the +-- table is skipped to prevent duplicates. If the table has a max_entries limit, +-- entries that exceed the limit are not added. +function table_utils.add_entry(device, table_name, entry) + device.log.debug("table_helpers: attempting to add entry " .. st_utils.stringify_table(entry) .. " to " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + if not validate_entry(device, entry, def.required_keys) then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + if #t >= table_utils.get_max_entries(device, table_name) then + device.log.warn(string.format( + "table_helpers: cannot add entry to %q, max entries reached", table_name + )) + return COMMAND_RESULT.RESOURCE_EXHAUSTED + end + + -- Object entry: skip if an entry with the same match_key value already exists + if def.match_key then + for _, existing in ipairs(t) do + if existing[def.match_key] == entry[def.match_key] then + device.log.warn(string.format( + "table_helpers: entry with %s == %s already exists in %q, skipping", + def.match_key, tostring(entry[def.match_key]), table_name + )) + return COMMAND_RESULT.OCCUPIED + end + end + end + + table.insert(t, st_utils.deep_copy(entry)) + + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS +end + + +--- Update fields of an existing entry in a table. +--- The entry to update is identified by the match_key parameter in DEFS. +function table_utils.update_entry(device, table_name, match_value, updates) + device.log.debug("table_helpers: attempting to update entry " .. match_value .. " in " .. table_name .. ": " .. st_utils.stringify_table(updates)) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + for _, entry in ipairs(t) do + if entry[def.match_key] == match_value then + for k, v in pairs(updates) do + entry[k] = v + end + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + + device.log.warn(string.format( + "table_helpers: no entry found in %q with %s == %s", + table_name, def.match_key, tostring(match_value) + )) + return COMMAND_RESULT.FAILURE +end + + +-- Delete an entry from a table. +-- +-- Returns SUCCESS, or FAILURE if nothing matched. +function table_utils.delete_entry(device, table_name, matcher) + device.log.debug("table_helpers: attempting to delete entry " .. matcher .. " from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + local t = table_utils.get_state(device, def) + if not t then return COMMAND_RESULT.FAILURE end + + local predicate = function(entry) return entry[def.match_key] == matcher end + + for i, entry in ipairs(t) do + if predicate(entry) then + table.remove(t, i) + device:emit_event(def.attribute(t, {visibility = {displayed = false}})) + persist_table(device, def, t) + return COMMAND_RESULT.SUCCESS + end + end + return COMMAND_RESULT.FAILURE +end + +-- Delete all entries from a table. +function table_utils.delete_all_entries(device, table_name) + device.log.debug("table_helpers: attempting to delete all entries from " .. table_name) + local def = resolve_table_def(device, table_name) + if not def then return COMMAND_RESULT.FAILURE end + device:emit_event(def.attribute({}, {visibility = {displayed = false}})) + persist_table(device, def, {}) + return COMMAND_RESULT.SUCCESS +end + +-- Restore capability state from the persistent field store. +-- Called during init to re-emit table events if the capability state cache +-- has been wiped (e.g. after a hub reboot). Only emits for tables that have +-- persisted data, are in a nil state, and whose capability is supported by the device. +function table_utils.restore_from_persistent_store(device) + for _, internal in pairs(DEFS) do + if device:supports_capability(internal.capability, "main") and + device:get_latest_state("main", internal.capability.ID, internal.attribute.NAME) == nil + then + local persisted = st_utils.deep_copy(device:get_field(internal.persistent_field)) + if persisted and #persisted > 0 then + device:emit_event(internal.attribute(persisted, {visibility = {displayed = false}})) + end + end + end +end + +return table_utils diff --git a/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua new file mode 100644 index 0000000000..64851e485a --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/lock_utils/utils.lua @@ -0,0 +1,160 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local consts = require "lock_utils.constants" +local tables = require "lock_utils.tables" + +local lock_utils = {} + +-- [[ BUSY STATE MANAGEMENT ]] -- + +-- Check if we are currently busy performing a task, or at least 10 seconds have passed since the busy state was last set. +-- If busy, return true. If not busy, clear any stale state and return false. +function lock_utils.is_device_busy(device) + local c_time = os.time() + local busy_since = device:get_field(consts.DRIVER_STATE.BUSY) or false + + if (busy_since == false) or (c_time - busy_since > 10) then + lock_utils.clear_busy_state(device) + return false + end + return true +end + +-- Set states that may be required when in busy state +function lock_utils.set_busy_state(device, command_name, command_args) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, command_name) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, command_args or {}) + device:set_field(consts.DRIVER_STATE.BUSY, os.time()) +end + +-- Clear states that were set when in busy state +function lock_utils.clear_busy_state(device) + device:set_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS, nil) + device:set_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE, nil) + device:set_field(consts.DRIVER_STATE.BUSY, false) +end + + +-- [[ SYNC STATE MANAGEMENT ]] -- + +function lock_utils.sync_device_state(device) + local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + + if (device:get_field(consts.SYNC.CODE_INDEX) == nil) then + device:set_field(consts.SYNC.CODE_INDEX, 1) + end + lock_utils.set_busy_state(device, consts.SYNC.CODES_FROM_LOCK) + device:send(UserCode:Get({user_identifier = device:get_field(consts.SYNC.CODE_INDEX)})) +end + + +-- [[ COMMAND RESULT STATE MANAGEMENT ]] -- + +function lock_utils.emit_command_result(device, capability, command_name, status_code, additional_info) + local info = additional_info or {} + info.commandName = command_name + info.statusCode = status_code + if capability then + device:emit_event(capability.commandResult(info, {state_change = true, visibility = {displayed = false}})) + end +end + + +-- [[ HELPERS ]] -- + +function lock_utils.get_code_id_from_notification_event(event_params, v1_alarm_level) + local code_id = v1_alarm_level + if event_params ~= nil and event_params ~= "" then + event_params = {event_params:byte(1,-1)} + code_id = (#event_params == 1) and event_params[1] or event_params[3] + end + return tostring(code_id) +end + +function lock_utils.set_credential_report_helper(device, credential_index) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) or {} + + local result_status + if command_in_progress == consts.LOCK_CREDENTIALS.ADD then + result_status = tables.add_entry(device, "credentials", { + userIndex = credential_args.userIndex, + credentialIndex = credential_args.credentialIndex, + credentialType = consts.CRED_TYPE_PIN, + credentialName = credential_args.credentialName, + }) + elseif command_in_progress == consts.LOCK_CREDENTIALS.UPDATE then + result_status = consts.COMMAND_RESULT.SUCCESS + elseif not tables.find_entry(device, "credentials", credential_index) then + -- credential does not exist and no add/update command in progress, therefore it was added out-of-band + -- check what user slot we have to associate this with, and add a default user + local next_available_index = tables.next_index(device, "users") + if next_available_index <= tables.get_max_entries(device, "users") then + tables.add_entry(device, "users", { + userIndex = next_available_index, + userName = "Guest " .. next_available_index, + userType = "guest", + }) + tables.add_entry(device, "credentials", { + userIndex = next_available_index, + credentialIndex = credential_index, + credentialType = consts.CRED_TYPE_PIN, + credentialName = "Guest " .. next_available_index, + }) + end + end + + -- emit command result + if command_in_progress and result_status then + local additional_info = result_status == consts.COMMAND_RESULT.SUCCESS and { + userIndex = credential_args.userIndex, + credentialIndex = credential_args.credentialIndex, + } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, command_in_progress, result_status, additional_info) + lock_utils.clear_busy_state(device) + end +end + +function lock_utils.delete_credential_report_helper(device, credential_index) + -- cached values from capability command + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + local credential_args_in_use = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + + local user_status, credential_status + if command_in_progress == consts.LOCK_USERS.DELETE then + user_status = tables.delete_entry(device, "users", credential_args_in_use.userIndex) + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE then + credential_status = tables.delete_entry(device, "credentials", credential_args_in_use.credentialIndex) + else + -- out-of-band deletion, find the credential index and associated user to delete + local credential = tables.find_entry(device, "credentials", credential_index) + if credential then + credential_status = tables.delete_entry(device, "credentials", credential_index) + local associated_user = tables.find_entry_by(device, "users", "userIndex", credential.userIndex) + if associated_user then + tables.delete_entry(device, "users", credential.userIndex) + end + end + end + + -- emit command results + if command_in_progress == consts.LOCK_USERS.DELETE and user_status and credential_status then + -- the deleteUser command injects a deleteCredential command, so both command results should be emitted in this case. + local user_info = user_status == consts.COMMAND_RESULT.SUCCESS and { userIndex = credential_args_in_use.userIndex } or nil + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockUsers, consts.LOCK_USERS.DELETE, user_status, user_info) + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + lock_utils.clear_busy_state(device) + elseif command_in_progress == consts.LOCK_CREDENTIALS.DELETE and credential_status then + local cred_info = credential_status == consts.COMMAND_RESULT.SUCCESS and { credentialIndex = credential_args_in_use.credentialIndex, userIndex = credential_args_in_use.userIndex } or nil + lock_utils.emit_command_result(device, capabilities.lockCredentials, consts.LOCK_CREDENTIALS.DELETE, credential_status, cred_info) + lock_utils.clear_busy_state(device) + end +end + + +return lock_utils diff --git a/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua new file mode 100644 index 0000000000..002d4c169d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/samsung-lock/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated then + local SAMSUNG_MFR = 0x022E + if device.zwave_manufacturer_id == SAMSUNG_MFR then + local subdriver = require("samsung-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua b/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua index 813c6217b4..80c3ce25d5 100644 --- a/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/samsung-lock/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -19,33 +8,9 @@ local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local access_control_event = Notification.event.access_control -local json = require "dkjson" -local constants = require "st.zwave.constants" - -local LockDefaults = require "st.zwave.defaults.lock" -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" -local get_lock_codes = LockCodesDefaults.get_lock_codes -local clear_code_state = LockCodesDefaults.clear_code_state -local code_deleted = LockCodesDefaults.code_deleted - -local SAMSUNG_MFR = 0x022E - -local function can_handle_samsung_lock(opts, self, device, cmd, ...) - return device.zwave_manufacturer_id == SAMSUNG_MFR -end - -local function get_ongoing_code_set(device) - local code_id - local code_state = device:get_field(constants.CODE_STATE) - if code_state ~= nil then - for key, state in pairs(code_state) do - if state ~= nil then - code_id = key:match("setName(%d)") - end - end - end - return code_id -end +local consts = require "lock_utils.constants" +local tables = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" local function notification_report_handler(self, device, cmd) local event @@ -54,36 +19,25 @@ local function notification_report_handler(self, device, cmd) if event_code == access_control_event.AUTO_LOCK_NOT_FULLY_LOCKED_OPERATION then event = capabilities.lock.lock.unlocked() elseif event_code == access_control_event.NEW_USER_CODE_ADDED then - local code_id = get_ongoing_code_set(device) - if code_id ~= nil then - device:send(UserCode:Get({user_identifier = code_id})) + local credential_args = device:get_field(consts.DRIVER_STATE.CREDENTIAL_ARGS_IN_USE) + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress == consts.LOCK_CREDENTIALS.ADD and credential_args ~= nil then + device:send(UserCode:Get({ user_identifier = credential_args.credentialIndex })) return end - elseif event_code == access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE then - local code_id = get_ongoing_code_set(device) - if code_id ~= nil then - event = capabilities.lockCodes.codeChanged(code_id .. " failed", { state_change = true }) - clear_code_state(device, code_id) - end elseif event_code == access_control_event.NEW_PROGRAM_CODE_ENTERED_UNIQUE_CODE_FOR_LOCK_CONFIGURATION then - -- Update Master Code in the same way as in defaults... - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - -- ...and delete rest of them, as lock does - local lock_codes = get_lock_codes(device) - for code_id, _ in pairs(lock_codes) do - if code_id ~= "0" then - code_deleted(device, code_id) - end - end - event = capabilities.lockCodes.lockCodes(json.encode(get_lock_codes(device)), { visibility = { displayed = false } }) + -- All other codes are deleted when the master code is changed + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") + return end end if event ~= nil then device:emit_event(event) else - LockDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, cmd) + zwave_handlers.door_operation_event_handler(self, device, cmd) + zwave_handlers.code_event_handler(self, device, cmd) end end @@ -92,7 +46,6 @@ local function do_configure(self, device) -- taken directly from DTH -- Samsung locks won't allow you to enter the pairing menu when locked, so it must be unlocked device:emit_event(capabilities.lock.lock.unlocked()) - device:emit_event(capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } })) end local samsung_lock = { @@ -105,7 +58,7 @@ local samsung_lock = { doConfigure = do_configure }, NAME = "Samsung Lock", - can_handle = can_handle_samsung_lock, + can_handle = require("samsung-lock.can_handle"), } return samsung_lock diff --git a/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua new file mode 100644 index 0000000000..d57135920d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/schlage-lock/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) + if slga_migrated then + local SCHLAGE_MFR = 0x003B + if device.zwave_manufacturer_id == SCHLAGE_MFR then + local subdriver = require("schlage-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua index 67e649d869..ee04fe42a0 100644 --- a/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/schlage-lock/init.lua @@ -1,92 +1,22 @@ --- Copyright 2022 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. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" local constants = require "st.zwave.constants" -local json = require "dkjson" local UserCode = (require "st.zwave.CommandClass.UserCode")({version=1}) local user_id_status = UserCode.user_id_status -local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local access_control_event = Notification.event.access_control local Configuration = (require "st.zwave.CommandClass.Configuration")({version=2}) local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) local Association = (require "st.zwave.CommandClass.Association")({version=1}) -local LockCodesDefaults = require "st.zwave.defaults.lockCodes" +local tables = require "lock_utils.tables" +local zwave_handlers = require "lock_handlers.zwave_responses" +local cap_handlers = require "lock_handlers.capabilities" -local SCHLAGE_MFR = 0x003B local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} -local DEFAULT_COMMANDS_DELAY = 4.2 -- seconds - -local function can_handle_schlage_lock(opts, self, device, cmd, ...) - return device.zwave_manufacturer_id == SCHLAGE_MFR -end - -local function set_code_length(self, device, cmd) - local length = cmd.args.length - if length >= 4 and length <= 8 then - device:send(Configuration:Set({ - parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, - configuration_value = length, - size = SCHLAGE_LOCK_CODE_LENGTH_PARAM.size - })) - end -end - -local function reload_all_codes(self, device, cmd) - LockCodesDefaults.capability_handlers[capabilities.lockCodes.commands.reloadAllCodes](self, device, cmd) - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) - if current_code_length == nil then - device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) - end -end - -local function set_code(self, device, cmd) - if (cmd.args.codePIN == "") then - self:inject_capability_command(device, { - capability = capabilities.lockCodes.ID, - command = capabilities.lockCodes.commands.nameSlot.NAME, - args = {cmd.args.codeSlot, cmd.args.codeName}, - }) - else - -- copied from defaults with additional check for Schlage's configuration - if (cmd.args.codeName ~= nil and cmd.args.codeName ~= "") then - if (device:get_field(constants.CODE_STATE) == nil) then device:set_field(constants.CODE_STATE, { persist = true }) end - local code_state = device:get_field(constants.CODE_STATE) - code_state["setName"..cmd.args.codeSlot] = cmd.args.codeName - device:set_field(constants.CODE_STATE, code_state, { persist = true }) - end - local send_set_user_code = function () - device:send(UserCode:Set({ - user_identifier = cmd.args.codeSlot, - user_code = cmd.args.codePIN, - user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS}) - ) - end - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) - if current_code_length == nil then - device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) - device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, send_set_user_code) - else - send_set_user_code() - end - end -end - local function do_configure(self, device) device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) device:send(Association:Set({grouping_identifier = 2, node_ids = {self.environment_info.hub_zwave_id}})) @@ -101,15 +31,14 @@ local function configuration_report(self, device, cmd) local parameter_number = cmd.args.parameter_number if parameter_number == SCHLAGE_LOCK_CODE_LENGTH_PARAM.number then local reported_code_length = cmd.args.configuration_value - local current_code_length = device:get_latest_state("main", capabilities.lockCodes.ID, capabilities.lockCodes.codeLength.NAME) + local current_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) if current_code_length ~= nil and current_code_length ~= reported_code_length then - local all_codes_deleted_mocked_command = Notification:Report({ - notification_type = Notification.notification_type.ACCESS_CONTROL, - event = access_control_event.ALL_USER_CODES_DELETED - }) - LockCodesDefaults.zwave_handlers[cc.NOTIFICATION][Notification.REPORT](self, device, all_codes_deleted_mocked_command) + -- when the code length is changed, all the codes have been wiped + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") end - device:emit_event(capabilities.lockCodes.codeLength(reported_code_length)) + device:emit_event(capabilities.lockCredentials.minPinCodeLen(reported_code_length)) + device:emit_event(capabilities.lockCredentials.maxPinCodeLen(reported_code_length)) end end @@ -129,49 +58,35 @@ local function is_user_code_report_mfr_specific(device, cmd) end local function user_code_report_handler(self, device, cmd) - local code_id = cmd.args.user_identifier + local credential_index = cmd.args.user_identifier if is_user_code_report_mfr_specific(device, cmd) then local reported_user_id_status = cmd.args.user_id_status - local user_code = cmd.args.user_code - local event - if reported_user_id_status == user_id_status.ENABLED_GRANT_ACCESS or -- OCCUPIED in UserCodeV1 - (reported_user_id_status == user_id_status.STATUS_NOT_AVAILABLE and user_code ~= nil) then - local code_name = LockCodesDefaults.get_code_name(device, code_id) - local change_type = LockCodesDefaults.get_change_type(device, code_id) - event = capabilities.lockCodes.codeChanged(code_id..""..change_type, { state_change = true }) - event.data = {codeName = code_name} - if code_id ~= 0 then -- ~= MASTER_CODE - LockCodesDefaults.code_set_event(device, code_id, code_name) - end - elseif code_id == 0 and reported_user_id_status == user_id_status.AVAILABLE then - local lock_codes = LockCodesDefaults.get_lock_codes(device) - for _code_id, _ in pairs(lock_codes) do - LockCodesDefaults.code_deleted(device, _code_id) - end - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(LockCodesDefaults.get_lock_codes(device)), { visibility = { displayed = false } })) - else -- user_id_status.STATUS_NOT_AVAILABLE - event = capabilities.lockCodes.codeChanged(code_id.." failed", { state_change = true }) + if credential_index == 0 and reported_user_id_status == user_id_status.AVAILABLE then + -- master code changed, clear all credentials + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") end + else + zwave_handlers.user_code_report(self, device, cmd) + end +end - if event ~= nil then - device:emit_event(event) - end - LockCodesDefaults.clear_code_state(device, code_id) - LockCodesDefaults.verify_set_code_completion(device, cmd, code_id) +local function add_credential_handler(self, device, cmd) + local DEFAULT_COMMANDS_DELAY = 4.2 + local current_code_length = device:get_latest_state("main", capabilities.lockCredentials.ID, capabilities.lockCredentials.minPinCodeLen.NAME) + local base_handler = function() + cap_handlers.add_credential(self, device, cmd) + end + if current_code_length == nil then + device:send(Configuration:Get({parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number})) + device.thread:call_with_delay(DEFAULT_COMMANDS_DELAY, base_handler) else - LockCodesDefaults.zwave_handlers[cc.USER_CODE][UserCode.REPORT](self, device, cmd) + base_handler() end end local schlage_lock = { - capability_handlers = { - [capabilities.lockCodes.ID] = { - [capabilities.lockCodes.commands.setCodeLength.NAME] = set_code_length, - [capabilities.lockCodes.commands.reloadAllCodes.NAME] = reload_all_codes, - [capabilities.lockCodes.commands.setCode.NAME] = set_code - } - }, zwave_handlers = { [cc.USER_CODE] = { [UserCode.REPORT] = user_code_report_handler @@ -183,11 +98,16 @@ local schlage_lock = { [Basic.SET] = basic_set_handler } }, + capability_handlers = { + [capabilities.lockCredentials.ID] = { + [capabilities.lockCredentials.commands.addCredential.NAME] = add_credential_handler + } + }, lifecycle_handlers = { doConfigure = do_configure, }, NAME = "Schlage Lock", - can_handle = can_handle_schlage_lock, + can_handle = require("schlage-lock.can_handle"), } return schlage_lock diff --git a/drivers/SmartThings/zwave-lock/src/sub_drivers.lua b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua new file mode 100644 index 0000000000..af5dc4d41f --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/sub_drivers.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-alarm-v1-lock"), + lazy_load_if_possible("schlage-lock"), + lazy_load_if_possible("samsung-lock"), + lazy_load_if_possible("keywe-lock"), + lazy_load_if_possible("legacy-handlers"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua new file mode 100644 index 0000000000..d521e2d3a0 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_init_lifecycle_handlers.lua @@ -0,0 +1,135 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lifecycle handlers defined in init.lua: +-- added (device_added), infoChanged (info_changed), init (LockLifecycle.init) +-- +-- Removed: doConfigure tests — z-wave drivers have no doConfigure lifecycle event. +-- Removed: init SLGA_MIGRATED without lockCodes test — z-wave init sends no protocol +-- messages; there is no NumberOfPINUsersSupported read on init. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +-- base-lock profile: lock + lockCodes + lockCredentials + lockUsers + battery +local mock_device_base = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- lock-battery profile: lock + battery only (no lockCodes / lockCredentials) +local mock_device_battery = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("lock-battery.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local function make_test_init(device) + return function() + test.disable_startup_messages() + test.mock_device.add_test_device(device) + end +end + +-- ============================================================================ +-- added (device_added) +-- ============================================================================ + +test.register_coroutine_test( + "added: non-TYPED (PROVISIONED) device with lockCodes does NOT emit migrated but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "added" }) + + -- No migrated capability event expected + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_base.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_base.id)) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device_base.id)) + test.wait_for_events() + + assert( + mock_device_base:get_field(constants.DRIVER_STATE.SLGA_MIGRATED) ~= true, + "SLGA_MIGRATED must NOT be set for a non-TYPED device" + ) + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "added: device without lockCodes does NOT emit migrated event but still injects refresh", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) + + -- No migrated event; lock-battery has no lockCredentials so no UsersNumberGet + test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device_battery.id)) + test.socket.zwave:__expect_send(Battery:Get({}):build_test_tx(mock_device_battery.id)) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +-- init (LockLifecycle.init) +-- ============================================================================ + +test.register_coroutine_test( + "init: device with lockCodes and SLGA_MIGRATED=true emits migrated + supportedCredentials", + function() + mock_device_base:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCodes.migrated(true, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device_base:generate_test_message("main", + capabilities.lockCredentials.supportedCredentials({ "pin" }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: device with lockCodes but SLGA_MIGRATED not set does nothing", + function() + -- SLGA_MIGRATED is not set; no events or z-wave sends expected + test.socket.device_lifecycle:__queue_receive({ mock_device_base.id, "init" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_base) } +) + +test.register_coroutine_test( + "init: lock-battery device (no lockCodes) does nothing regardless of SLGA_MIGRATED", + function() + mock_device_battery:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + -- lock-battery has no lockCodes → if branch is false → no events, no z-wave sends + test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) + test.wait_for_events() + end, + { test_init = make_test_init(mock_device_battery) } +) + +-- ============================================================================ +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua index d8e8ecc1bc..2b9d2d3d72 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock.lua @@ -1,23 +1,16 @@ --- Copyright 2022 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. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) + +test.disable_startup_messages() local KEYWE_MANUFACTURER_ID = 0x037B local KEYWE_PRODUCT_TYPE = 0x0002 @@ -34,22 +27,41 @@ local zwave_lock_endpoints = { local mock_device = test.mock_device.build_test_zwave_device( { - profile = t_utils.get_profile_definition("base-lock.yml"), + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + _provisioning_state = "TYPED", zwave_endpoints = zwave_lock_endpoints, zwave_manufacturer_id = KEYWE_MANUFACTURER_ID, zwave_product_type = KEYWE_PRODUCT_TYPE, - zwave_product_id = KEYWE_PRODUCT_ID + zwave_product_id = KEYWE_PRODUCT_ID, } ) local function test_init() test.mock_device.add_test_device(mock_device) end + test.set_test_init_function(test_init) +local function added() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.zwave:__expect_send( + DoorLock:OperationGet({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + Battery:Get({}):build_test_tx(mock_device.id) + ) + test.socket.zwave:__expect_send( + UserCode:UsersNumberGet({}):build_test_tx(mock_device.id) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) + test.wait_for_events() +end + test.register_coroutine_test( "Door Lock Operation Reports unlocked should be handled", function() + added() test.socket.zwave:__queue_receive({mock_device.id, DoorLock:OperationReport({door_lock_mode = 0x00}) }) @@ -60,6 +72,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Door Lock Operation Reports locked should be handled", function() + added() test.socket.zwave:__queue_receive({mock_device.id, DoorLock:OperationReport({door_lock_mode = 0xFF}) }) @@ -67,36 +80,18 @@ test.register_coroutine_test( end ) -test.register_message_test( +test.register_coroutine_test( "Lock notification reporting should be handled", - { - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Notification:Report({notification_type = 6, event = 24}) - } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}})) - }, - { - channel = "zwave", - direction = "receive", - message = { - mock_device.id, - Notification:Report({notification_type = 6, event = 25}) - } - }, + function() + added() + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({notification_type = 6, event = 24}) } ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}}))) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({notification_type = 6, event = 25}) } ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}}))) + end +, { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}})) - } - } -) + min_api_version = 17 + }) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua new file mode 100644 index 0000000000..63a8729ce9 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_keywe_lock_legacy.lua @@ -0,0 +1,101 @@ +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + + +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) + +local KEYWE_MANUFACTURER_ID = 0x037B +local KEYWE_PRODUCT_TYPE = 0x0002 +local KEYWE_PRODUCT_ID = 0x0001 + +-- supported comand classes +local zwave_lock_endpoints = { + { + command_classes = { + {value = DoorLock} + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = KEYWE_MANUFACTURER_ID, + zwave_product_type = KEYWE_PRODUCT_TYPE, + zwave_product_id = KEYWE_PRODUCT_ID + } +) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Door Lock Operation Reports unlocked should be handled", + function() + test.socket.zwave:__queue_receive({mock_device.id, + DoorLock:OperationReport({door_lock_mode = 0x00}) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked())) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Door Lock Operation Reports locked should be handled", + function() + test.socket.zwave:__queue_receive({mock_device.id, + DoorLock:OperationReport({door_lock_mode = 0xFF}) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked())) + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Lock notification reporting should be handled", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Notification:Report({notification_type = 6, event = 24}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.unlocked({data={method="manual"}})) + }, + { + channel = "zwave", + direction = "receive", + message = { + mock_device.id, + Notification:Report({notification_type = 6, event = 25}) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({data={method="manual"}})) + } + }, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_battery.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_battery.lua index 9aac02c6b2..3254edf58a 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_lock_battery.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_battery.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -68,7 +58,10 @@ test.register_coroutine_test( DoorLock:OperationReport({ door_lock_mode = DoorLock.door_lock_mode.DOOR_UNSECURED }) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.unlocked())) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -84,6 +77,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(50)) } + }, + { + min_api_version = 14 } ) @@ -100,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -114,7 +113,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(4.2) test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -132,7 +134,31 @@ test.register_coroutine_test( minute_local_time = time.min, second_local_time = time.sec }):build_test_tx(mock_device.id)) - end + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "The driver should respond correctly to a date get", + function () + test.socket.zwave:__queue_receive({ mock_device.id, Time:DateGet({},{ + encap = zw.ENCAP.AUTO, + src_channel = 0, + dst_channels = {} + }) + }) + local time = os.date("*t") + test.socket.zwave:__expect_send(Time:DateReport({ + year = time.year, + month = time.month, + day = time.day + }):build_test_tx(mock_device.id)) + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua new file mode 100644 index 0000000000..21729e507d --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_code_slga_migration.lua @@ -0,0 +1,127 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) +local t_utils = require "integration_test.utils" +--- @type st.zwave.constants +local constants = require "st.zwave.constants" +local lock_utils = require "lock_utils.constants".DRIVER_STATE + +local SCHLAGE_MANUFACTURER_ID = 0x003B +local SCHLAGE_PRODUCT_TYPE = 0x0002 +local SCHLAGE_PRODUCT_ID = 0x0469 + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION } + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } +) + +local schlage_mock_device = test.mock_device.build_test_zwave_device( + { + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, + zwave_manufacturer_id = SCHLAGE_MANUFACTURER_ID, + zwave_product_type = SCHLAGE_PRODUCT_TYPE, + zwave_product_id = SCHLAGE_PRODUCT_ID + } +) + +local SCHLAGE_LOCK_CODE_LENGTH_PARAM = {number = 16, size = 1} + +local function init_code_slot(slot_number, name, device) + local lock_codes = device.persistent_store[constants.LOCK_CODES] + if lock_codes == nil then + lock_codes = {} + device.persistent_store[constants.LOCK_CODES] = lock_codes + end + lock_codes[tostring(slot_number)] = name +end + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(schlage_mock_device) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Device called 'migrate' command", + function() + init_code_slot(1, "Zach", mock_device) + init_code_slot(5, "Steven", mock_device) + -- setup codes + test.socket.zwave:__queue_receive({mock_device.id, UserCode:UsersNumberReport({ supported_users = 4 }) }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.wait_for_events() + -- Validate migrate command + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + end +) + +test.register_coroutine_test( + "Migrate new device", + function() + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockUsers.users({}, { visibility = { displayed = false } }))) + test.socket.zwave:__expect_send(UserCode:UsersNumberGet({}):build_test_tx(mock_device.id)) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + assert(mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + end +) + +test.register_coroutine_test( + "Schlage-Lock device called 'migrate' command, validate codeLength is being properly set", + function() + init_code_slot(1, "Zach", schlage_mock_device) + init_code_slot(5, "Steven", schlage_mock_device) + -- setup codes + test.socket.zwave:__queue_receive({schlage_mock_device.id, UserCode:UsersNumberReport({ supported_users = 4 }) }) + test.socket.zwave:__queue_receive({schlage_mock_device.id, Configuration:Report({ parameter_number = SCHLAGE_LOCK_CODE_LENGTH_PARAM.number, configuration_value = 6 })}) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(6))) + test.wait_for_events() + -- Validate migrate command + test.socket.capability:__queue_receive({ schlage_mock_device.id, { capability = capabilities.lockCodes.ID, command = "migrate", args = {} } }) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.credentials({{credentialIndex=1, credentialType="pin", userIndex=1}, {credentialIndex=5, credentialType="pin", userIndex=2}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.users({{userIndex=1, userName="Zach", userType="guest"}, {userIndex=2, userName="Steven", userType="guest"}}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.minPinCodeLen(6, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.maxPinCodeLen(6, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.pinUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockUsers.totalUsersSupported(4, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCredentials.supportedCredentials({"pin"}, { visibility = { displayed = false } }))) + test.socket.capability:__expect_send( schlage_mock_device:generate_test_message("main", capabilities.lockCodes.migrated(true, { visibility = { displayed = false } }))) + test.wait_for_events() + assert(schlage_mock_device:get_field(lock_utils.SLGA_MIGRATED) == true, "Device should be marked as migrated") + end +) + +test.run_registered_tests() \ No newline at end of file diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua new file mode 100644 index 0000000000..5daee2191a --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_credentials_commands.lua @@ -0,0 +1,519 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockCredentials capability commands: +-- addCredential, updateCredential, deleteCredential, deleteAllCredentials + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local json = require "st.json" +local zw = require "st.zwave" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local access_control_event = Notification.event.access_control + +test.disable_startup_messages() + + +if table_utils.find_all_entries_by == nil then + function table_utils.find_all_entries_by(device, table_name, key, value) + local entries = table_utils.get_state(device, table_name) or {} + local matches = {} + for _, entry in ipairs(entries) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches + end +end + + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + mock_device.get_latest_state = function(_, component_id, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(component_id, capability_id, attribute_name)] + if value == nil then + if capability_id == capabilities.lockCredentials.ID and attribute_name == capabilities.lockCredentials.credentials.NAME then + value = mock_device.persistent_store.persistedCredentials + elseif capability_id == capabilities.lockUsers.ID and attribute_name == capabilities.lockUsers.users.NAME then + value = mock_device.persistent_store.persistedUsers + end + end + if value == nil then return default_value end + return value + end + local original_set_field = mock_device.set_field + mock_device.set_field = function(_, key, value, opts) + if opts and opts.persist then + mock_device.persistent_store[key] = value + else + mock_device.transient_store[key] = value + end + if original_set_field then original_set_field(mock_device, key, value, opts) end + end + mock_device.emit_event = function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end +end + +-- ── helpers ──────────────────────────────────────────────────────────────── + +local function test_init() + mock_device.persistent_store = mock_device.persistent_store or {} + mock_device.transient_store = mock_device.transient_store or {} + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, { persist = true }) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function queue_user_code_added(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(user_identifier), + }) }) +end + +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + local event = capabilities.lockUsers.users(so_far, { visibility = { displayed = false } }) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + end + mock_device.persistent_store.persistedUsers = entries + test.wait_for_events() +end + +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + local event = capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } }) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + end + mock_device.persistent_store.persistedCredentials = entries + test.wait_for_events() +end + +-- ============================================================================ +-- addCredential +-- ============================================================================ + +test.register_coroutine_test( + "addCredential: sends SetPINCode to the lock and emits success when the lock acknowledges", + function() + -- Queue the capability command: userIndex=1, userType="guest", credentialType="pin", credentialData="1234" + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + -- Expect SetPINCode to be sent to the lock + expect_set_pin_code(1, "1234") + test.wait_for_events() + + -- Lock responds with SUCCESS + queue_user_code_added(1) + + -- Handler adds the credential to the credentials table. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + -- commandResult with userIndex and credentialIndex + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: emits duplicate when the lock returns DUPLICATE_CODE", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + + expect_set_pin_code(1, "1234") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE, event_parameter = string.char(1) }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "addCredential: returns busy when another operation is already in progress", + function() + -- Put the device into busy state by starting an addCredential operation + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 1, "guest", "pin", "1234" } }, + }) + expect_set_pin_code(1, "1234") + test.wait_for_events() + + -- Second addCredential while first is still pending → should get busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "5678" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +-- ============================================================================ +-- updateCredential +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential: returns failure when cached credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin9" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateCredential: returns failure immediately when the credential does not exist in the table", + function() + -- No credentials seeded — credentialIndex 99 does not exist + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + -- No z-wave message should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "updateCredential: returns busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "pin2222" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +-- ============================================================================ +-- deleteCredential +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential: returns failure when credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "deleteCredential: returns failure when credential state is unavailable", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "deleteCredential: returns failure immediately when the credentialIndex does not exist in the table", + function() + -- No credentials seeded — index 5 is unknown + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 5, "pin" } }, + }) + + -- No z-wave messages should be sent + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "deleteCredential: returns busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +-- ============================================================================ +-- deleteAllCredentials +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllCredentials: sends ClearAllPINCodes and emits success when the lock returns PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.wait_for_events() + + test.mock_time.advance_time(4) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "deleteAllCredentials: emits busy when another operation is already in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.register_coroutine_test( + "deleteAllCredentials: returns busy when another operation is already in progress", + function() + seed_credentials({ { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }) + + -- First deleteAllCredentials starts the z-wave flow (device is now busy) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + -- deleteAllCredentials sends individual UserCode:Set AVAILABLE commands + test.wait_for_events() + + -- Second deleteAllCredentials while first is pending → busy + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteAllCredentials", args = {} }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end, + { + min_api_version = 15 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua new file mode 100644 index 0000000000..ef264e0c88 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_pre_configured.lua @@ -0,0 +1,544 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for lockUsers and lockCredentials commands with state +-- pre-configured before each test. Two users and two credentials are seeded +-- at the start of every test so tests can focus on the various response states +-- produced by the z-wave response handlers in commands.lua. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local access_control_event = Notification.event.access_control +local json = require "st.json" + + +test.disable_startup_messages() +if table_utils.find_all_entries_by == nil then + function table_utils.find_all_entries_by(device, table_name, key, value) + local entries = table_utils.get_state(device, table_name) or {} + local matches = {} + for _, entry in ipairs(entries) do + if entry[key] == value then table.insert(matches, entry) end + end + return matches + end +end + + +-- ── Shared device ────────────────────────────────────────────────────────── + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- Lightweight mock state so that table_utils functions work on mock_device +-- before the driver has lazily initialised the wrapped device. After the +-- driver processes its first message (wrapped_init), MockDevice.__index +-- delegates every field access to the real driver device, so the overrides +-- below are only ever active during the pre-init seeding phase. +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) + + -- get_state guards with device:supports_capability; always return true here. + rawset(mock_device, "supports_capability", function() return true end) + + -- get_state / get_max_entries use device:get_latest_state to read + -- capability attribute values from the state cache. + rawset(mock_device, "get_latest_state", + function(_, component_id, capability_id, attribute_name, default_value) + local key = mock_state_key(component_id, capability_id, attribute_name) + local value = mock_latest_state[key] + if value == nil then return default_value end + return value + end + ) + + -- add_entry / delete_entry / update_entry all call device:emit_event. + -- Forward to the capability socket so __expect_send checks pass, and + -- keep mock_latest_state in sync so that successive get_state calls + -- within the same seeding loop see the growing list. + rawset(mock_device, "emit_event", function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end) +end + +local function test_init() + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function expect_clear_pin_code(user_identifier) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(mock_device.id) + ) +end + +local function queue_user_code_added(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.NEW_USER_CODE_ADDED, + event_parameter = string.char(user_identifier), + }) }) +end + +local function queue_user_code_deleted(user_identifier) + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = access_control_event.SINGLE_USER_CODE_DELETED, + event_parameter = string.char(user_identifier), + }) }) +end + +-- ── Seeding helpers ──────────────────────────────────────────────────────── + +local INITIAL_USERS = { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, +} +local INITIAL_CREDS = { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, +} + +-- Seed a list of entries into a named table, consuming the resulting events. +local function seed_table(attribute_fn, table_name, entries) + local accumulated = {} + for _, entry in ipairs(entries) do + table.insert(accumulated, entry) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + attribute_fn(accumulated, { visibility = { displayed = false } })) + ) + assert( + table_utils.add_entry(mock_device, table_name, entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_table: add_entry failed for " .. table_name + ) + end + test.wait_for_events() +end + +-- Pre-configure each test with 2 users and 2 credentials. +local function setup_state() + seed_table(capabilities.lockUsers.users, "users", INITIAL_USERS) + seed_table(capabilities.lockCredentials.credentials, "credentials", INITIAL_CREDS) +end + +-- ============================================================================ +-- addUser — pre-configured device (2 users already present) +-- ============================================================================ + +test.register_coroutine_test( + "addUser (pre-configured): assigns the next available index (3) when two users already exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- updateUser — pre-configured device +-- ============================================================================ + +test.register_coroutine_test( + "updateUser (pre-configured): updates Alice's name and emits success with userIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceRenamed", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceRenamed", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "updateUser (pre-configured): returns failure for a userIndex that does not exist", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 10, "Ghost", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 10, userName = "Ghost", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 10 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- deleteUser with associated credential — exercises clear_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser (pre-configured, PASS): removes both user and credential and emits success for each", + function() + setup_state() + + -- Delete user 1 who has credential 1 → driver injects deleteCredential → ClearPINCode + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + expect_clear_pin_code(1) + test.wait_for_events() + + queue_user_code_deleted(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- addCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "addCredential (pre-configured): succeeds for a new slot and emits userIndex + credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03", "Guest 3" } }, + }) + + expect_set_pin_code(3, "pin03") + test.wait_for_events() + + queue_user_code_added(1) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin", credentialName = "Guest 3" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "success", userIndex = 3, credentialIndex = 3 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "addCredential (pre-configured): emits duplicate when the lock rejects with DUPLICATE_CODE", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin01" } }, + }) + + expect_set_pin_code(3, "pin01") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = access_control_event.NEW_USER_CODE_NOT_ADDED_DUE_TO_DUPLICATE_CODE, event_parameter = string.char(1) }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "duplicate" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "addCredential (pre-configured): emits failure when the lock returns available", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 3, "guest", "pin", "pin03" } }, + }) + + expect_set_pin_code(3, "pin03") + test.wait_for_events() + + test.socket.zwave:__queue_receive({ mock_device.id, UserCode:Report({ user_identifier = 1, user_id_status = UserCode.user_id_status.AVAILABLE }) }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "addCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- updateCredential response states — set_pin_code_response +-- ============================================================================ + +test.register_coroutine_test( + "updateCredential (pre-configured): succeeds and emits success with userIndex and credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 1, 1, "pin", "newPin1" } }, + }) + + expect_set_pin_code(1, "newPin1") + test.wait_for_events() + + queue_user_code_added(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "success", userIndex = 1, credentialIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "updateCredential (pre-configured): returns failure immediately for a non-existent credentialIndex", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "updateCredential", + args = { 99, 99, "pin", "badPin" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "updateCredential", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- deleteCredential standalone (lockCredentials.DELETE path) +-- ============================================================================ + +test.register_coroutine_test( + "deleteCredential (pre-configured, PASS): removes credential and emits success with indices", + function() + setup_state() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "deleteCredential", + args = { 1, "pin" } }, + }) + expect_clear_pin_code(1) + test.wait_for_events() + + queue_user_code_deleted(1) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 2, credentialIndex = 2, credentialType = "pin" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteCredential", statusCode = "success", credentialIndex = 1, userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua new file mode 100644 index 0000000000..63f846cf49 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_tables.lua @@ -0,0 +1,761 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Unit tests for lock_utils/tables.lua +-- Tests directly call table_utils functions and verify both return values +-- and emitted capability events. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local json = require "st.json" +local capabilities = require "st.capabilities" +local st_utils = require "st.utils" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" + +test.disable_startup_messages() + +-- --------------------------------------------------------------------------- +-- Shared mock device +-- --------------------------------------------------------------------------- + +local zw = require "st.zwave" + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local mock_latest_state = {} +local mock_persistent_fields = {} +local mock_transient_fields = {} + +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_table_utils_device_mocks() + mock_latest_state = {} + mock_persistent_fields = {} + mock_transient_fields = {} + mock_device.supports_capability = function() return true end + mock_device.log = { error = function() end, warn = function() end } + mock_device.get_latest_state = function(_, component_id, capability_id, attribute_name, default_value) + local value = mock_latest_state[mock_state_key(component_id, capability_id, attribute_name)] + if value == nil then return default_value end + return value + end + mock_device.emit_event = function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end + mock_device.set_field = function(_, key, value, opts) + if opts and opts.persist then + mock_persistent_fields[key] = value + else + mock_transient_fields[key] = value + end + end + mock_device.get_field = function(_, key) + if mock_transient_fields[key] ~= nil then return mock_transient_fields[key] end + return mock_persistent_fields[key] + end + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) +end + + +local function test_init() + test.mock_device.add_test_device(mock_device) + install_table_utils_device_mocks() +end +test.set_test_init_function(test_init) + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- Seed the users table with `entries` and consume the resulting emit_events. +-- After this call the state cache has those entries and the socket is clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + -- Build the expected post-insert table up to this entry. + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for entry userIndex=" .. tostring(entry.userIndex)) + end +end + +-- Seed the credentials table with `entries` and consume the resulting emit_events. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local expected = {} + for _, e in ipairs(entries) do + table.insert(expected, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(expected, { visibility = { displayed = false } })) + ) + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for entry credentialIndex=" .. tostring(entry.credentialIndex)) + end +end + +-- =========================================================================== +-- add_entry +-- =========================================================================== + +test.register_coroutine_test( + "add_entry: adds a new user entry and emits the updated table", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "users", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns OCCUPIED when an entry with the same userIndex already exists", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- Attempt to add a different entry with the same userIndex (match_key) + local duplicate = { userIndex = 1, userType = "unrestricted", userName = "Bob" } + local result = table_utils.add_entry(mock_device, "users", duplicate) + assert(result == constants.COMMAND_RESULT.OCCUPIED, + "Expected OCCUPIED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns RESOURCE_EXHAUSTED when table is at max capacity", + function() + local entries = {} + for i = 1, 20 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local overflow = { userIndex = 21, userType = "guest", userName = "Overflow" } + local result = table_utils.add_entry(mock_device, "users", overflow) + assert(result == constants.COMMAND_RESULT.RESOURCE_EXHAUSTED, + "Expected RESOURCE_EXHAUSTED, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE when a required key is missing", + function() + -- userType is required for users table + local incomplete = { userIndex = 1 } + local result = table_utils.add_entry(mock_device, "users", incomplete) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.add_entry(mock_device, "nonexistent_table", + { userIndex = 1, userType = "guest" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "add_entry: adds a credential entry and emits updated credentials table", + function() + local entry = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ entry }, { visibility = { displayed = false } })) + ) + + local result = table_utils.add_entry(mock_device, "credentials", entry) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- update_entry +-- =========================================================================== + +test.register_coroutine_test( + "update_entry: updates an existing user entry and emits updated table", + function() + local original = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ original }) + + local expected_after_update = { + { userIndex = 1, userType = "adminMember", userName = "Alice_Updated" } + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_after_update, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 1, + { userName = "Alice_Updated", userType = "adminMember" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.update_entry(mock_device, "users", 99, + { userName = "Ghost" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: only updates the specified fields, leaves others intact", + function() + local entry1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local entry2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ entry1, entry2 }) + + -- Update only userName of entry 2; userType should remain "guest" + local expected = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 2, userType = "guest", userName = "Bob_Renamed" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected, { visibility = { displayed = false } })) + ) + + local result = table_utils.update_entry(mock_device, "users", 2, { userName = "Bob_Renamed" }) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "update_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.update_entry(mock_device, "bad_table", 1, { userName = "X" }) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_entry +-- =========================================================================== + +test.register_coroutine_test( + "delete_entry: deletes an existing entry and returns COMMAND_RESULT.SUCCESS", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + -- After deletion the table is empty + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 1) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE when no entry matches the match_key value", + function() + local result = table_utils.delete_entry(mock_device, "users", 99) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: remaining entries are intact after a deletion", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + -- Delete the middle entry; expect e1 and e3 remain + local expected_remaining = { + { userIndex = 1, userType = "guest", userName = "Alice" }, + { userIndex = 3, userType = "guest", userName = "Carol" }, + } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(expected_remaining, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_entry(mock_device, "users", 2) + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_entry: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_entry(mock_device, "bad_table", 1) + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- delete_all_entries +-- =========================================================================== + +test.register_coroutine_test( + "delete_all_entries: emits an empty users table and returns SUCCESS", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: returns SUCCESS even when the table is already empty", + function() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "users") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: returns FAILURE for an unknown table name", + function() + local result = table_utils.delete_all_entries(mock_device, "bad_table") + assert(result == constants.COMMAND_RESULT.FAILURE, + "Expected FAILURE, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "delete_all_entries: emits an empty credentials table and returns SUCCESS", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + seed_credentials({ cred }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + + local result = table_utils.delete_all_entries(mock_device, "credentials") + assert(result == constants.COMMAND_RESULT.SUCCESS, + "Expected SUCCESS, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- find_entry +-- =========================================================================== + +test.register_coroutine_test( + "find_entry: returns the matching entry when it exists", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry(mock_device, "users", 2) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + assert(result.userName == "Bob", + "Expected userName == 'Bob', got: " .. tostring(result.userName)) + end +) + +test.register_coroutine_test( + "find_entry: returns nil when no entry matches the value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry(mock_device, "users", 99) + assert(result == nil, + "Expected nil for missing entry, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry: finds a credential entry by credentialIndex", + function() + local cred = { userIndex = 1, credentialIndex = 5, credentialType = "pin" } + seed_credentials({ cred }) + + local result = table_utils.find_entry(mock_device, "credentials", 5) + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.credentialIndex == 5, + "Expected credentialIndex == 5, got: " .. tostring(result.credentialIndex)) + end +) + +-- =========================================================================== +-- find_entry_by +-- =========================================================================== + +test.register_coroutine_test( + "find_entry_by: returns the entry matching an arbitrary key", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "adminMember", userName = "Bob" } + seed_users({ e1, e2 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Bob") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 2, + "Expected userIndex == 2, got: " .. tostring(result.userIndex)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns nil when no entry matches", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ e1 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userName", "Nobody") + assert(result == nil, + "Expected nil for unmatched key/value, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "find_entry_by: returns the first matching entry when multiple entries share the same value", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e2, e3 }) + + local result = table_utils.find_entry_by(mock_device, "users", "userType", "guest") + assert(type(result) == "table", + "Expected a table entry, got: " .. tostring(result)) + assert(result.userIndex == 1, + "Expected first match userIndex == 1, got: " .. tostring(result.userIndex)) + end +) + +-- =========================================================================== +-- next_index +-- =========================================================================== + +test.register_coroutine_test( + "next_index: returns 1 when the table is empty", + function() + local result = table_utils.next_index(mock_device, "users") + assert(result == 1, + "Expected 1 for empty table, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "next_index: returns the next sequential index after a contiguous range", + function() + local entries = {} + for i = 1, 3 do + entries[i] = { userIndex = i, userType = "guest", userName = "User" .. i } + end + seed_users(entries) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 4, + "Expected 4 after indices 1-3, got: " .. tostring(result)) + end +) + +-- this should not happen during normal operation. +test.register_coroutine_test( + "next_index: returns the lowest gap when indices are non-contiguous", + function() + -- Insert entries at indices 1 and 3, leaving a gap at 2 + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e3 = { userIndex = 3, userType = "guest", userName = "Carol" } + seed_users({ e1, e3 }) + + local result = table_utils.next_index(mock_device, "users") + assert(result == 2, + "Expected 2 as the lowest gap, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- get_max_entries +-- =========================================================================== + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "users") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +test.register_coroutine_test( + "get_max_entries: returns the default of 20 for the credentials table when the attribute is not set", + function() + local result = table_utils.get_max_entries(mock_device, "credentials") + assert(result == 20, + "Expected default max of 20, got: " .. tostring(result)) + end +) + +-- =========================================================================== +-- Persistence — mutations write to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: add_entry writes the new users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ entry }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "users", entry) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 1 and persisted[1].userName == "Alice", + "Persisted user data does not match the added entry") + end +) + +test.register_coroutine_test( + "persist: update_entry writes the updated users table to the persistent store immediately", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userType = "adminMember", userName = "Alice" } }, + { visibility = { displayed = false } })) + ) + table_utils.update_entry(mock_device, "users", 1, { userType = "adminMember" }) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after update") + assert(persisted[1].userType == "adminMember", + "Persisted user type was not updated, got: " .. tostring(persisted[1].userType)) + end +) + +test.register_coroutine_test( + "persist: delete_entry removes the entry from the persistent store immediately", + function() + local e1 = { userIndex = 1, userType = "guest", userName = "Alice" } + local e2 = { userIndex = 2, userType = "guest", userName = "Bob" } + seed_users({ e1, e2 }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ e2 }, { visibility = { displayed = false } })) + ) + table_utils.delete_entry(mock_device, "users", 1) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted user after delete, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].userIndex == 2, + "Expected remaining user to have userIndex == 2, got: " .. tostring(persisted[1].userIndex)) + end +) + +test.register_coroutine_test( + "persist: delete_all_entries writes an empty table to the persistent store", + function() + local entry = { userIndex = 1, userType = "guest", userName = "Alice" } + seed_users({ entry }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + table_utils.delete_all_entries(mock_device, "users") + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedUsers")) + assert(type(persisted) == "table" and #persisted == 0, + "Expected empty persistent store after delete_all, got: " .. tostring(persisted and #persisted)) + end +) + +test.register_coroutine_test( + "persist: add_entry writes the new credentials table to the persistent store immediately", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.add_entry(mock_device, "credentials", cred) + + local persisted = st_utils.deep_copy(mock_device:get_field("persistedCredentials")) + assert(type(persisted) == "table" and #persisted == 1, + "Expected 1 persisted credential, got: " .. tostring(persisted and #persisted)) + assert(persisted[1].credentialIndex == 1, + "Persisted credential index does not match") + end +) + +-- =========================================================================== +-- Persistence — get_state falls back to the persistent store +-- =========================================================================== + +test.register_coroutine_test( + "persist: get_state returns data from the persistent store when capability state cache is absent", + function() + -- The device was loaded with a pre-seeded persistent field (set in test_init + -- below), but no capability event has been emitted for users, so get_latest_state + -- returns nil. get_state must fall back to the persistent store. + local state = table_utils.get_state(mock_device, "users") + assert(type(state) == "table" and #state == 1, + "Expected 1 user from persistent-store fallback, got: " .. tostring(state and #state)) + assert(state[1].userIndex == 1 and state[1].userName == "Alice", + "Fallback data does not match pre-seeded persistent entry") + end, + { + test_init = function() + -- Pre-seed persistent store BEFORE add_test_device so that wrapped_init + -- copies the field into the device's persistent_store on startup. + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +-- =========================================================================== +-- Persistence — restore_from_persistent_store re-emits stored tables +-- =========================================================================== + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits users capability event for stored data", + function() + local user = { userIndex = 1, userType = "guest", userName = "Alice" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({ user }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + end, + { + test_init = function() + install_table_utils_device_mocks() + mock_device:set_field( + "persistedUsers", + { { userIndex = 1, userType = "guest", userName = "Alice" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store emits credentials capability event for stored data", + function() + local cred = { userIndex = 1, credentialIndex = 1, credentialType = "pin" } + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({ cred }, { visibility = { displayed = false } })) + ) + table_utils.restore_from_persistent_store(mock_device) + end, + { + test_init = function() + install_table_utils_device_mocks() + mock_device:set_field( + "persistedCredentials", + { { userIndex = 1, credentialIndex = 1, credentialType = "pin" } }, + { persist = true } + ) + test.mock_device.add_test_device(mock_device) + end, + } +) + +test.register_coroutine_test( + "persist: restore_from_persistent_store is a no-op when the persistent store is empty", + function() + -- No capability events expected when there is nothing in the persistent store. + table_utils.restore_from_persistent_store(mock_device) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua new file mode 100644 index 0000000000..21c829c497 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_lock_users_commands.lua @@ -0,0 +1,744 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Integration tests for the lockUsers capability commands: +-- addUser, updateUser, deleteUser, deleteAllUsers + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local table_utils = require "lock_utils.tables" +local constants = require "lock_utils.constants" +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +local json = require "st.json" + + +test.disable_startup_messages() + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +-- ── helpers ──────────────────────────────────────────────────────────────── + +-- Lightweight mock state so that table_utils functions work on mock_device +-- before the driver has lazily initialised the wrapped device. After the +-- driver processes its first message (wrapped_init), MockDevice.__index +-- delegates every field access to the real driver device, so the overrides +-- below are only ever active during the pre-init seeding phase. +local mock_latest_state = {} +local function mock_state_key(component_id, capability_id, attribute_name) + return table.concat({ component_id, capability_id, attribute_name }, "|") +end + +local function install_state_mocks() + mock_latest_state = {} + + -- tables.lua calls device.log.{debug,warn,error} unconditionally. + rawset(mock_device, "log", { + debug = function() end, + info = function() end, + warn = function() end, + error = function() end, + }) + + -- get_state guards with device:supports_capability; always return true here. + rawset(mock_device, "supports_capability", function() return true end) + + -- get_state / get_max_entries use device:get_latest_state to read + -- capability attribute values from the state cache. + rawset(mock_device, "get_latest_state", + function(_, component_id, capability_id, attribute_name, default_value) + local key = mock_state_key(component_id, capability_id, attribute_name) + local value = mock_latest_state[key] + if value == nil then return default_value end + return value + end + ) + + -- add_entry / delete_entry / update_entry all call device:emit_event. + -- Forward to the capability socket so __expect_send checks pass, and + -- keep mock_latest_state in sync so that successive get_state calls + -- within the same seeding loop see the growing list. + rawset(mock_device, "emit_event", function(_, event) + mock_latest_state[mock_state_key("main", event.capability.ID, event.attribute.NAME)] = event.value.value + local message = mock_device:generate_test_message("main", event) + test.socket.capability:send(message[1], json.encode(message[2])) + end) +end + +local function test_init() + install_state_mocks() + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +local function expect_set_pin_code(user_identifier, user_code) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_code = user_code, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + }):build_test_tx(mock_device.id) + ) +end + +local function expect_clear_pin_code(user_identifier) + test.socket.zwave:__expect_send( + UserCode:Set({ + user_identifier = user_identifier, + user_id_status = UserCode.user_id_status.AVAILABLE, + }):build_test_tx(mock_device.id) + ) +end + +-- Directly insert users into the device state via table_utils (mirrors test_lock_tables.lua). +-- Consumes the resulting capability events so the socket queue stays clean. +local function seed_users(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "users", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_users: add_entry failed for userIndex=" .. tostring(entry.userIndex)) + end + test.wait_for_events() +end + +-- Directly insert credentials into the device state. +local function seed_credentials(entries) + for _, entry in ipairs(entries) do + local so_far = {} + for _, e in ipairs(entries) do + table.insert(so_far, e) + if e == entry then break end + end + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials(so_far, { visibility = { displayed = false } })) + ) + assert(table_utils.add_entry(mock_device, "credentials", entry) == constants.COMMAND_RESULT.SUCCESS, + "seed_credentials: add_entry failed for credentialIndex=" .. tostring(entry.credentialIndex)) + end + test.wait_for_events() +end + +-- Set totalUsersSupported (and pinUsersSupported) by injecting the Z-Wave +-- UsersNumberReport that the real users_number_report handler processes. +-- Using the Z-Wave path is essential: it goes through the driver's normal +-- emit_component_event path and populates the driver device's state cache, +-- which is what get_max_entries reads when enforcing the slot limit. +local function set_total_users_supported(n) + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:UsersNumberReport({ supported_users = n }) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported(n, { state_change = true, visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported(n, { state_change = true, visibility = { displayed = false } })) + ) + test.wait_for_events() +end + +-- ============================================================================ +-- addUser +-- ============================================================================ + +test.register_coroutine_test( + "addUser: assigns userIndex 1 for the first user and emits a success commandResult", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Alice", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "addUser: assigns the next sequential userIndex when users already exist", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "addUser: fills a gap left by a deleted user rather than appending beyond max", + function() + -- Seed indices 1 and 3; index 2 is the expected gap to fill + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Bob", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "addUser: returns resourceExhausted when totalUsersSupported has been reached", + function() + set_total_users_supported(2) + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Carol", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "resourceExhausted" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- updateUser +-- ============================================================================ + +test.register_coroutine_test( + "updateUser: updates an existing user and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "AliceUpdated", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "AliceUpdated", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "updateUser: creates user when userIndex does not exist", + function() + -- empty table — nothing to update + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 99, "Ghost", "guest" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 99, userName = "Ghost", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 99 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "updateUser: can change a user's type as well as name", + function() + seed_users({ { userIndex = 1, userName = "Alice", userType = "guest" } }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "updateUser", args = { 1, "Alice", "adminMember" } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Alice", userType = "adminMember" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "updateUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- deleteUser (no associated credential — pure local delete) +-- ============================================================================ + +test.register_coroutine_test( + "deleteUser: removes a user with no credential and emits a success commandResult with userIndex", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 1 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 2, userName = "Bob", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 1 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "deleteUser: returns failure when the target userIndex is not in the users table", + function() + -- empty table + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 99 } }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "failure" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- deleteAllUsers (injects deleteAllCredentials → ClearAllPINCodes z-wave flow) +-- ============================================================================ + +test.register_coroutine_test( + "deleteAllUsers: sends ClearAllPINCodes and emits success for both users and credentials on PASS", + function() + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.timer.__create_and_queue_test_time_advance_timer(0, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(0.5, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(8, "oneshot") + test.mock_time.advance_time(0) + expect_clear_pin_code(1) + test.wait_for_events() + test.mock_time.advance_time(0.5) + expect_clear_pin_code(2) + test.wait_for_events() + + -- The legacy subdriver consumes deletion notifications for this mock profile; advance + -- to the driver's cleanup timer, which clears both tables and reports success. + test.mock_time.advance_time(8) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials({}, { visibility = { displayed = false } })) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.commandResult( + { commandName = "deleteAllCredentials", statusCode = "success" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "deleteAllUsers: emits busy when another lock command is in progress", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.LOCK_CREDENTIALS.ADD, {}) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteAllUsers", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteAllUsers", statusCode = "busy" }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- State-consistency: add → delete → re-add (indices 1, 2, 3 lifecycle) +-- ============================================================================ +-- +-- These tests verify that the users and credentials tables stay in sync +-- through a full lifecycle: populate three slots, remove the middle one, +-- then re-add a user and credential into the freed slot. The goal is to +-- confirm there is no stale index state that would cause duplicate entries, +-- wrong slot assignment, or mismatched user↔credential links. + + +test.register_coroutine_test( + "State-consistency: add users 1-3, deleteUser 2 (no credential), re-add user reclaims index 2", + function() + -- Populate three user slots directly (no credentials, so deleteUser will take + -- the no-Z-Wave path and delete the user entry locally). + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + + -- Delete user at index 2; no credential is linked so this is a pure local delete. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "deleteUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a user. next_index sees occupied = {1, 3}, so it assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "State-consistency: add users+credentials 1-3, deleteUser 2 (with credential), re-add user+credential reclaims index 2 cleanly", + function() + -- Populate three user and credential slots. + seed_users({ + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 2, userName = "Bob", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }) + seed_credentials({ + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }) + + -- Delete user at index 2. The handler finds a linked credential and injects + -- deleteCredential, which sends ClearPINCode to the lock. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "deleteUser", args = { 2 } }, + }) + expect_clear_pin_code(2) + test.wait_for_events() + + -- Lock acknowledges the deletion. + -- The legacy subdriver handles deletion notifications for this mock profile, so clear local + -- state directly to model the intended post-delete state before testing index reuse. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.delete_entry(mock_device, "users", 2) == constants.COMMAND_RESULT.SUCCESS) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.delete_entry(mock_device, "credentials", 2) == constants.COMMAND_RESULT.SUCCESS) + test.wait_for_events() + mock_device:set_field(constants.DRIVER_STATE.BUSY, false, {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, nil, {}) + + -- Re-add a user. next_index sees occupied = {1, 3} and assigns index 2. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockUsers.ID, command = "addUser", args = { "Dave", "guest" } }, + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { + { userIndex = 1, userName = "Alice", userType = "guest" }, + { userIndex = 3, userName = "Carol", userType = "guest" }, + { userIndex = 2, userName = "Dave", userType = "guest" }, + }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.commandResult( + { commandName = "addUser", statusCode = "success", userIndex = 2 }, + { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + -- Re-add a credential for the new user at slot 2. The old credential at + -- credentialIndex 2 was cleanly removed, so add_entry must succeed without + -- returning OCCUPIED or any stale-state error. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = capabilities.lockCredentials.ID, command = "addCredential", + args = { 2, "guest", "pin", "9999" } }, + }) + expect_set_pin_code(2, "9999") + test.wait_for_events() + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + { userIndex = 3, credentialIndex = 3, credentialType = "pin" }, + { userIndex = 2, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 2" }, + }, + { visibility = { displayed = false } } + )) + ) + assert(table_utils.add_entry(mock_device, "credentials", { + userIndex = 2, + credentialIndex = 2, + credentialType = "pin", + credentialName = "Guest 2", + }) == constants.COMMAND_RESULT.SUCCESS) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua similarity index 91% rename from drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua rename to drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua index 7707b8a850..42cb17a2a2 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_samsung_lock_legacy.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -59,7 +49,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"}), { visibility = { displayed = false } })) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -102,7 +95,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -116,7 +112,10 @@ test.register_coroutine_test( }) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("2 failed", { state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -130,7 +129,10 @@ test.register_coroutine_test( }) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged(2 .. " failed", { state_change = true }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -172,7 +174,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({["0"] = "Master Code"} ), { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua similarity index 92% rename from drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua rename to drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua index b1a5964502..3c8a165f7f 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_schlage_lock_legacy.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -85,7 +75,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -102,7 +95,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -118,7 +114,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(6)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -147,7 +146,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.codeLength(4)) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -163,7 +165,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({}), {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) local expect_reload_all_codes_messages = function() @@ -197,7 +202,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -222,7 +230,10 @@ test.register_coroutine_test( ) ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -246,7 +257,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua deleted file mode 100644 index e4a9b50758..0000000000 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_code_migration.lua +++ /dev/null @@ -1,246 +0,0 @@ --- Copyright 2022 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. - --- Mock out globals -local test = require "integration_test" -local capabilities = require "st.capabilities" -local zw = require "st.zwave" ---- @type st.zwave.CommandClass.DoorLock -local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) ---- @type st.zwave.CommandClass.Battery -local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.CommandClass.UserCode -local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) -local t_utils = require "integration_test.utils" -local zw_test_utils = require "integration_test.zwave_test_utils" -local utils = require "st.utils" - -local mock_datastore = require "integration_test.mock_env_datastore" - -local json = require "dkjson" - -local zwave_lock_endpoints = { - { - command_classes = { - { value = zw.BATTERY }, - { value = zw.DOOR_LOCK }, - { value = zw.USER_CODE }, - { value = zw.NOTIFICATION } - } - } -} - -local lockCodes = { - ["1"] = "Zach", - ["2"] = "Steven" -} - -local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - zwave_endpoints = zwave_lock_endpoints, - data = { - lockCodes = json.encode(utils.deep_copy(lockCodes)) - } - } -) - -local mock_device_no_data = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - data = {} - } -) - -local expect_reload_all_codes_messages = function(dev, lc) - test.socket.capability:__expect_send(dev:generate_test_message("main", - capabilities.lockCodes.lockCodes(json.encode(lc), { visibility = { displayed = false } }) - )) - test.socket.zwave:__expect_send( UserCode:UsersNumberGet({}):build_test_tx(dev.id) ) - test.socket.capability:__expect_send(dev:generate_test_message("main", capabilities.lockCodes.scanCodes("Scanning", { visibility = { displayed = false } }))) - test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(dev.id) ) -end - -test.register_coroutine_test( - "Device added data lock codes population", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device added without data should function", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - expect_reload_all_codes_messages(mock_device_no_data,{}) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device_no_data:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", nil) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode({})) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - end -) - -test.register_coroutine_test( - "Device init after added shouldn't change the datastores", - function() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - end -) - -test.register_coroutine_test( - "Device init after added with no data should update the datastores", - function() - test.mock_device.add_test_device(mock_device_no_data) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "added" }) - -- This should happen as the data is empty at this point - expect_reload_all_codes_messages(mock_device_no_data, {}) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device_no_data, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device_no_data:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", nil) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode({})) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", nil) - test.socket.device_lifecycle():__queue_receive(mock_device_no_data:generate_info_changed( - { - data = { - lockCodes = json.encode(utils.deep_copy(lockCodes)) - } - } - )) - test.socket.device_lifecycle:__queue_receive({ mock_device_no_data.id, "init" }) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device_no_data.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device_no_data.id, "migrationComplete", true) - end -) - - -test.register_coroutine_test( - "Device added data lock codes population, should not reload all codes", - function() - test.timer.__create_and_queue_test_time_advance_timer(31, "oneshot") - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - test.wait_for_events() - -- Validate lockCodes field - mock_datastore.__assert_device_store_contains(mock_device.id, "_lock_codes", { ["1"] = "Zach", ["2"] = "Steven" }) - -- Validate state cache - assert(mock_device.state_cache.main.lockCodes.lockCodes.value == json.encode(utils.deep_copy(lockCodes))) - -- Validate migration complete flag - mock_datastore.__assert_device_store_contains(mock_device.id, "migrationComplete", true) - test.wait_for_events() - test.mock_time.advance_time(35) - -- Nothing should happen - end -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua similarity index 94% rename from drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua rename to drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua index 52144295b3..b7ddf56227 100644 --- a/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock.lua +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_lock_legacy.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -43,10 +33,10 @@ local zwave_lock_endpoints = { } local mock_device = test.mock_device.build_test_zwave_device( - { - profile = t_utils.get_profile_definition("base-lock-tamper.yml"), - zwave_endpoints = zwave_lock_endpoints - } + { + profile = t_utils.get_profile_definition("base-lock-tamper.yml"), + zwave_endpoints = zwave_lock_endpoints + } ) local function test_init() @@ -63,28 +53,6 @@ local expect_reload_all_codes_messages = function() test.socket.zwave:__expect_send( UserCode:Get({ user_identifier = 1 }):build_test_tx(mock_device.id) ) end -test.register_coroutine_test( - "When the device is added it should be set up and start reading codes", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - - expect_reload_all_codes_messages() - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - DoorLock:OperationGet({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_device, - Battery:Get({}) - ) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end -) - test.register_coroutine_test( "Door Lock Operation Reports should be handled", function() @@ -92,7 +60,10 @@ test.register_coroutine_test( DoorLock:OperationReport({door_lock_mode = DoorLock.door_lock_mode.DOOR_SECURED}) }) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lock.lock.locked())) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -108,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -129,6 +103,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lock.lock.locked({ data = { method = "manual" } })) } + }, + { + min_api_version = 14 } ) @@ -159,7 +136,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -182,6 +160,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -196,7 +177,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(4.2) test.socket.zwave:__expect_send(DoorLock:OperationGet({}):build_test_tx(mock_device.id)) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -212,6 +196,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.lockCodes.maxCodes(16, { visibility = { displayed = false } })) } + }, + { + min_api_version = 14 } ) @@ -220,7 +207,10 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.lockCodes.ID, command = "reloadAllCodes", args = {} } }) expect_reload_all_codes_messages() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -236,6 +226,9 @@ test.register_message_test( direction = "send", message = UserCode:Get({user_identifier = 1}):build_test_tx(mock_device.id) } + }, + { + min_api_version = 14 } ) @@ -249,7 +242,10 @@ test.register_coroutine_test( test.mock_time.advance_time(4.2) test.socket.zwave:__expect_send(UserCode:Get( {user_identifier = 1}):build_test_tx(mock_device.id)) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -266,7 +262,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) local function init_code_slot(slot_number, name, device) @@ -288,7 +287,10 @@ test.register_coroutine_test( )) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 renamed", {state_change = true}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -312,7 +314,10 @@ test.register_coroutine_test( test.mock_time.advance_time(2) test.socket.zwave:__expect_send(UserCode:Get({user_identifier = 4}):build_test_tx(mock_device.id)) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -334,6 +339,9 @@ test.register_message_test( capabilities.lockCodes.codeChanged("0 set", { data = { codeName = "Master Code"}, state_change = true }) ) } + }, + { + min_api_version = 14 } ) @@ -362,7 +370,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -384,7 +393,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -424,7 +436,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.lockCodes(json.encode({} ), { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -446,7 +461,10 @@ test.register_coroutine_test( capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Superb Owl" } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -459,7 +477,7 @@ test.register_coroutine_test( Notification:Report({ notification_type = Notification.notification_type.ACCESS_CONTROL, event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, - event_parameter = "" + event_parameter = "\x01" }) } ) @@ -468,7 +486,10 @@ test.register_coroutine_test( capabilities.lock.lock.unlocked({ data = { method = "keypad", codeId = "1", codeName = "Code 1" } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -496,7 +517,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.scanCodes("Complete", { visibility = { displayed = false } }) )) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -728,6 +752,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -750,7 +777,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.lockCodes.codeChanged("1 set", { data = { codeName = "test"}, state_change = true })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -769,8 +799,17 @@ test.register_coroutine_test( Battery:Get({}) ) ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, + UserCode:UsersNumberGet({}) + ) + ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua new file mode 100644 index 0000000000..24db26a3ba --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/test/test_zwave_responses.lua @@ -0,0 +1,426 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Tests for lock_handlers/zwave_responses.lua +-- Covers: +-- • user_code_report: sync-codes-from-lock flow (advances slot, completes sync) +-- • user_code_report: out-of-band ENABLED code creates user+credential entries +-- • user_code_event_handler (via Notification:Report): NEW_USER_CODE_ADDED out-of-band +-- • user_code_event_handler: multi-byte event_parameter (byte 3 carries user_id) +-- • door_operation_event_handler: KEYPAD_UNLOCK with user info lookup +-- • door_operation_event_handler: KEYPAD_UNLOCK with unknown credential +-- • door_operation_event_handler: AUTO_LOCK_LOCKED_OPERATION → locked/auto +-- • DoorLock:OperationReport DOOR_SECURED / DOOR_UNSECURED → locked / unlocked +-- • UserCode:UsersNumberReport → pinUsersSupported + totalUsersSupported +-- +-- Removed: alarm tests — z-wave alarm decoding is only in the legacy-handlers sub-driver +-- for non-SLGA devices; not applicable to the main SLGA driver path. +-- Removed: max/min PIN code length tests — no z-wave equivalent attribute. +-- Removed: NOT_FULLY_LOCKED lock_state test — no clean z-wave OperationReport equivalent. +-- Removed: NumberOfPINUsersSupported profile-migration tests — that logic lives in the +-- zigbee legacy sub-driver; users_number_report in z-wave only emits events. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local constants = require "lock_utils.constants" + +--- @type st.zwave.CommandClass.DoorLock +local DoorLock = (require "st.zwave.CommandClass.DoorLock")({ version = 1 }) +--- @type st.zwave.CommandClass.UserCode +local UserCode = (require "st.zwave.CommandClass.UserCode")({ version = 1 }) +--- @type st.zwave.CommandClass.Notification +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) + +local zwave_lock_endpoints = { + { + command_classes = { + { value = zw.BATTERY }, + { value = zw.DOOR_LOCK }, + { value = zw.USER_CODE }, + { value = zw.NOTIFICATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("base-lock.yml"), + zwave_endpoints = zwave_lock_endpoints, +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device) + mock_device:set_field(constants.DRIVER_STATE.SLGA_MIGRATED, true, {}) +end +test.set_test_init_function(test_init) + +-- ============================================================================ +-- user_code_report: sync codes from lock +-- During sync (CODES_FROM_LOCK busy state), ENABLED slots advance the index +-- without adding table entries; only AVAILABLE slots remove local entries. +-- ============================================================================ + +test.register_coroutine_test( + "user_code_report: sync advances to next code slot when slot is occupied", + function() + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 1, {}) + + -- Receive UserCode:Report for slot 1 (occupied) + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + + -- driver requests the next code slot. + test.socket.zwave:__expect_send( + UserCode:Get({ user_identifier = 2 }):build_test_tx(mock_device.id) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "user_code_report: sync completes when the last slot is reached", + function() + -- get_max_entries defaults to 20 when the attribute is missing + mock_device:set_field(constants.DRIVER_STATE.BUSY, os.time(), {}) + mock_device:set_field(constants.DRIVER_STATE.COMMAND_IN_PROGRESS, constants.SYNC.CODES_FROM_LOCK, {}) + mock_device:set_field(constants.SYNC.CODE_INDEX, 20, {}) + + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 20, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 20, userName = "Guest 20", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 20, credentialIndex = 20, credentialType = "pin", credentialName = "Guest 20" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + + assert(mock_device:get_field(constants.SYNC.CODE_INDEX) == nil, + "CODE_INDEX must be nil after sync completes") + assert(mock_device:get_field(constants.DRIVER_STATE.BUSY) == false, + "BUSY must be false after sync completes") + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "user_code_report: out-of-band ENABLED code creates user and credential entries", + function() + -- command_in_progress == nil → out-of-band path + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:Report({ + user_identifier = 1, + user_id_status = UserCode.user_id_status.ENABLED_GRANT_ACCESS, + user_code = "1234", + }), + }) + + -- User entry added + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + -- Credential entry added + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- user_code_event_handler (ACCESS_CONTROL Notification:Report) +-- ============================================================================ + +test.register_coroutine_test( + "user_code_event: NEW_USER_CODE_ADDED out-of-band creates user and credential entries", + function() + -- event_parameter = single byte encodes user_id = 1 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_ADDED, + event_parameter = string.char(1), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 1, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "user_code_event: multi-byte event_parameter uses byte 3 to extract user_id", + function() + -- 3-byte event_parameter: bytes are (0, 0, 2) → byte 3 = 2 → user_id = 2 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.NEW_USER_CODE_ADDED, + event_parameter = string.char(0, 0, 2), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.users( + { { userIndex = 1, userName = "Guest 1", userType = "guest" } }, + { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.credentials( + { { userIndex = 1, credentialIndex = 2, credentialType = "pin", credentialName = "Guest 1" } }, + { visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- door_operation_event_handler (ACCESS_CONTROL Notification:Report) +-- ============================================================================ + +test.register_coroutine_test( + "door_operation_event: KEYPAD_UNLOCK_OPERATION includes user info when credential exists", + function() + -- Seed persisted user/credential tables so the driver's lookup succeeds. + mock_device:set_field("persistedUsers", { + { userIndex = 1, userName = "John Doe", userType = "guest" }, + }, { persist = true }) + mock_device:set_field("persistedCredentials", { + { userIndex = 1, credentialIndex = 1, credentialType = "pin" }, + }, { persist = true }) + + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, + event_parameter = string.char(1), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 1, + userName = "John Doe", + userType = "guest", + }, + }) + ) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "door_operation_event: KEYPAD_UNLOCK_OPERATION with unknown credential sets userIndex from event_parameter", + function() + -- No credential seeded for slot 99 + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.KEYPAD_UNLOCK_OPERATION, + event_parameter = string.char(99), + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.unlocked({ + data = { + method = "keypad", + userIndex = 99, + }, + }) + ) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "door_operation_event: AUTO_LOCK_LOCKED_OPERATION emits locked with auto method", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + Notification:Report({ + notification_type = Notification.notification_type.ACCESS_CONTROL, + event = Notification.event.access_control.AUTO_LOCK_LOCKED_OPERATION, + event_parameter = "", + }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lock.lock.locked({ data = { method = "auto" } }) + ) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- DoorLock:OperationReport → lock state +-- Removed: NOT_FULLY_LOCKED — no clean z-wave OperationReport equivalent. +-- ============================================================================ + +test.register_coroutine_test( + "OperationReport: DOOR_SECURED emits locked event", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + DoorLock:OperationReport({ door_lock_mode = DoorLock.door_lock_mode.DOOR_SECURED }), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.locked()) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.register_coroutine_test( + "OperationReport: DOOR_UNSECURED emits unlocked event", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + DoorLock:OperationReport({ door_lock_mode = DoorLock.door_lock_mode.DOOR_UNSECURED }), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.lock.lock.unlocked()) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +-- ============================================================================ +-- users_number_report: UserCode:UsersNumberReport +-- ============================================================================ + +test.register_coroutine_test( + "users_number_report: emits pinUsersSupported and totalUsersSupported", + function() + test.socket.zwave:__queue_receive({ + mock_device.id, + UserCode:UsersNumberReport({ supported_users = 20 }), + }) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockUsers.totalUsersSupported( + 20, { state_change = true, visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.lockCredentials.pinUsersSupported( + 20, { state_change = true, visibility = { displayed = false } } + )) + ) + test.wait_for_events() + end +, + { + min_api_version = 15 + }) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua new file mode 100644 index 0000000000..42a80c6f66 --- /dev/null +++ b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, cmd) + local consts = require("lock_utils.constants") + local slga_migrated = device:get_field(consts.DRIVER_STATE.SLGA_MIGRATED) or false + if slga_migrated then + if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 then + local subdriver = require("zwave-alarm-v1-lock") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua index 44d978999b..fa9836498b 100644 --- a/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua +++ b/drivers/SmartThings/zwave-lock/src/zwave-alarm-v1-lock/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -19,9 +8,10 @@ local cc = require "st.zwave.CommandClass" local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) --- @type st.zwave.CommandClass.Battery local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) ---- @type st.zwave.defaults.lockCodes -local lock_code_defaults = require "st.zwave.defaults.lockCodes" -local json = require "dkjson" + +local consts = require "lock_utils.constants" +local lock_utils = require "lock_utils.utils" +local tables = require "lock_utils.tables" local METHOD = { KEYPAD = "keypad", @@ -30,15 +20,6 @@ local METHOD = { AUTO = "auto" } ---- Determine whether the passed command is a V1 alarm command ---- ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @return boolean true if the device is smoke co alarm -local function can_handle_v1_alarm(opts, driver, device, cmd, ...) - return opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version == 1 -end - --- Default handler for alarm command class reports, these were largely OEM-defined --- --- This converts alarm V1 reports to correct lock events @@ -48,84 +29,66 @@ end --- @param cmd st.zwave.CommandClass.Alarm.Report local function alarm_report_handler(driver, device, cmd) local alarm_type = cmd.args.alarm_type - local event = nil - local lock_codes = lock_code_defaults.get_lock_codes(device) - local code_id = nil - if (cmd.args.alarm_level ~= nil) then - code_id = tostring(cmd.args.alarm_level) - end + local credential_index = cmd.args.alarm_level + local event if (alarm_type == 9 or alarm_type == 17) then event = capabilities.lock.lock.unknown() elseif (alarm_type == 16 or alarm_type == 19) then event = capabilities.lock.lock.unlocked() - if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + if (credential_index ~= nil) then + local credential = tables.find_entry(device, "credentials", credential_index) + local user_id = credential and credential.userIndex or nil + event.data = { userIndex = user_id, method = METHOD.KEYPAD} end elseif (alarm_type == 18) then event = capabilities.lock.lock.locked() - if (device:supports_capability(capabilities.lockCodes) and code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - event.data = {codeId = code_id, codeName = code_name, method = METHOD.KEYPAD} + if (credential_index ~= nil) then + local credential = tables.find_entry(device, "credentials", credential_index) + local user_id = credential and credential.userIndex or nil + event.data = { userIndex = user_id, method = METHOD.KEYPAD} end elseif (alarm_type == 21) then event = capabilities.lock.lock.locked() - if (cmd.args.alarm_level == 2) then - event["data"] = {method = METHOD.MANUAL} - else - event["data"] = {method = METHOD.KEYPAD} - end + event.data = {method = (cmd.args.alarm_level == 2) and METHOD.MANUAL or METHOD.KEYPAD} elseif (alarm_type == 22) then event = capabilities.lock.lock.unlocked() - event["data"] = {method = METHOD.MANUAL} + event.data = {method = METHOD.MANUAL} elseif (alarm_type == 23) then event = capabilities.lock.lock.unknown() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 24) then event = capabilities.lock.lock.locked() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 25) then event = capabilities.lock.lock.unlocked() - event["data"] = {method = METHOD.COMMAND} + event.data = {method = METHOD.COMMAND} elseif (alarm_type == 26) then event = capabilities.lock.lock.unknown() - event["data"] = {method = METHOD.AUTO} + event.data = {method = METHOD.AUTO} elseif (alarm_type == 27) then event = capabilities.lock.lock.locked() - event["data"] = {method = METHOD.AUTO} + event.data = {method = METHOD.AUTO} elseif (alarm_type == 32) then - -- all user codes deleted - for code_id, _ in pairs(lock_codes) do - lock_code_defaults.code_deleted(device, code_id) - end - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) + -- all credentials have been deleted + tables.delete_all_entries(device, "credentials") + tables.delete_all_entries(device, "users") elseif (alarm_type == 33) then - -- user code deleted - if (code_id ~= nil) then - lock_code_defaults.clear_code_state(device, code_id) - if (lock_codes[code_id] ~= nil) then - lock_code_defaults.code_deleted(device, code_id) - device:emit_event(capabilities.lockCodes.lockCodes(json.encode(lock_code_defaults.get_lock_codes(device)), { visibility = { displayed = false } })) - end - end + -- credential has been deleted + lock_utils.delete_credential_report_helper(device, credential_index) + elseif (alarm_type == 13 or alarm_type == 112) then -- user code changed/set - if (code_id ~= nil) then - local code_name = lock_code_defaults.get_code_name(device, code_id) - local change_type = lock_code_defaults.get_change_type(device, code_id) - local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. change_type, { state_change = true }) - code_changed_event["data"] = { codeName = code_name} - lock_code_defaults.code_set_event(device, code_id, code_name) - lock_code_defaults.clear_code_state(device, code_id) - device:emit_event(code_changed_event) - end + lock_utils.set_credential_report_helper(device, credential_index) + elseif (alarm_type == 34 or alarm_type == 113) then - -- duplicate lock code - if (code_id ~= nil) then - local code_changed_event = capabilities.lockCodes.codeChanged(code_id .. lock_code_defaults.CHANGE_TYPE.FAILED, { state_change = true }) - lock_code_defaults.clear_code_state(device, code_id) - device:emit_event(code_changed_event) + -- duplicate lock code. Log duplicate error + local command_in_progress = device:get_field(consts.DRIVER_STATE.COMMAND_IN_PROGRESS) + if command_in_progress then + lock_utils.emit_command_result(device, capabilities.lockCredentials, + command_in_progress, consts.COMMAND_RESULT.DUPLICATE) + lock_utils.clear_busy_state(device) end + elseif (alarm_type == 130) then -- batteries replaced if (device:is_cc_supported(cc.BATTERY)) then @@ -159,7 +122,7 @@ local zwave_lock = { } }, NAME = "Z-Wave lock alarm V1", - can_handle = can_handle_v1_alarm, + can_handle = require("zwave-alarm-v1-lock.can_handle") } return zwave_lock diff --git a/drivers/SmartThings/zwave-mouse-trap/src/test/test_zwave_mouse_trap.lua b/drivers/SmartThings/zwave-mouse-trap/src/test/test_zwave_mouse_trap.lua index 3e973a14f8..ca1ae9ede4 100644 --- a/drivers/SmartThings/zwave-mouse-trap/src/test/test_zwave_mouse_trap.lua +++ b/drivers/SmartThings/zwave-mouse-trap/src/test/test_zwave_mouse_trap.lua @@ -65,6 +65,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.pestExterminated()) } + }, + { + min_api_version = 14 } ) @@ -84,6 +87,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.idle()) } + }, + { + min_api_version = 14 } ) @@ -103,6 +109,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.trapArmed()) } + }, + { + min_api_version = 14 } ) @@ -122,6 +131,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.trapArmed()) } + }, + { + min_api_version = 14 } ) @@ -141,6 +153,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.trapRearmRequired()) } + }, + { + min_api_version = 14 } ) @@ -160,6 +175,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.pestDetected()) } + }, + { + min_api_version = 14 } ) @@ -179,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.pestDetected()) } + }, + { + min_api_version = 14 } ) @@ -198,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.pestExterminated()) } + }, + { + min_api_version = 14 } ) @@ -217,6 +241,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.pestControl.pestControl.idle()) } + }, + { + min_api_version = 14 } ) @@ -233,6 +260,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.battery.battery(85)) } + }, + { + min_api_version = 14 } ) @@ -249,6 +279,9 @@ test.register_message_test( direction = "send", message = mock_mouse_trap:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -300,7 +333,10 @@ test.register_coroutine_test( ) ) mock_mouse_trap:expect_metadata_update({provisioning_state = "PROVISIONED"}) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/fingerprints.yml b/drivers/SmartThings/zwave-sensor/fingerprints.yml index 727f02a2d6..0810b20a38 100644 --- a/drivers/SmartThings/zwave-sensor/fingerprints.yml +++ b/drivers/SmartThings/zwave-sensor/fingerprints.yml @@ -446,11 +446,23 @@ zwaveManufacturer: productId: 0x000B deviceProfileName: contact-battery-tamperalert - id: 027A/7000/E001 - deviceLabel: Zooz Open/Closed Sensor + deviceLabel: Open Close XS Sensor manufacturerId: 0x027A productType: 0x7000 productId: 0xE001 - deviceProfileName: contact-battery-tamperalert + deviceProfileName: base-contact + - id: 027A/0201/0006 + deviceLabel: Q Sensor + manufacturerId: 0x027A + productType: 0x0201 + productId: 0x0006 + deviceProfileName: multi-functional-motion + - id: 027A/7000/E004 + deviceLabel: Temperature Humidity XS Sensor + manufacturerId: 0x027A + productType: 0x7000 + productId: 0xE004 + deviceProfileName: humidity-temperature-battery - id: "aeotec/multisensor/7" deviceLabel: Aeotec Multipurpose Sensor manufacturerId: 0x0371 @@ -502,12 +514,12 @@ zwaveManufacturer: productType: 0x4C47 productId: 0x4C44 deviceProfileName: base-water - - id: 027A/7000/E002 - deviceLabel: Zooz Water Leak Sensor + - id: "Zooz/ZSE42" + deviceLabel: Zooz ZSE42 XS Water Leak Sensor manufacturerId: 0x027A productType: 0x7000 productId: 0xE002 - deviceProfileName: base-water + deviceProfileName: zooz-zse42-water - id: 010F/0800 deviceLabel: Fibaro Motion Sensor manufacturerId: 0x010F diff --git a/drivers/SmartThings/zwave-sensor/profiles/zooz-zse42-water.yml b/drivers/SmartThings/zwave-sensor/profiles/zooz-zse42-water.yml new file mode 100644 index 0000000000..1a497e7438 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/profiles/zooz-zse42-water.yml @@ -0,0 +1,52 @@ +name: zooz-zse42-water +components: + - id: main + capabilities: + - id: waterSensor + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + - id: firmwareUpdate + version: 1 + categories: + - name: LeakSensor +preferences: + #param 1 + - name: "ledIndicator" + title: "LED Indicator" + description: "When enabled the LED indicator will blink continuously when water is detected." + required: false + preferenceType: enumeration + definition: + options: + 1: "Enabled *" + 0: "Disabled" + default: 1 + #param 2 + - name: "leakAlertClearDelay" + title: "Leak Alert Clear Delay" + description: "Default = 0; How long the sensor will wait before sending a 'dry' report to your hub after water is no longer detected." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 3600 + default: 0 + #param 4 + - name: "lowBatteryAlert" + title: "Low Battery Alert" + description: "Battery level threshold for low battery reports." + required: false + preferenceType: enumeration + definition: + options: + 10: "10%" + 15: "15%" + 20: "20% *" + 25: "25%" + 30: "30%" + 40: "40%" + 50: "50%" + default: 20 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/can_handle.lua new file mode 100644 index 0000000000..d9956517dd --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_multisensor(opts, self, device, ...) + local FINGERPRINTS = require("aeotec-multisensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subdriver = require("aeotec-multisensor") + return true, subdriver + end + end + return false +end + +return can_handle_aeotec_multisensor diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/fingerprints.lua new file mode 100644 index 0000000000..9436a85979 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_MULTISENSOR_FINGERPRINTS = { + { manufacturerId = 0x0086, productId = 0x0064 }, -- MultiSensor 6 + { manufacturerId = 0x0371, productId = 0x0018 }, -- MultiSensor 7 +} + +return AEOTEC_MULTISENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/init.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/init.lua index edd01c7553..947f2fea2b 100644 --- a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,21 +7,6 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local AEOTEC_MULTISENSOR_FINGERPRINTS = { - { manufacturerId = 0x0086, productId = 0x0064 }, -- MultiSensor 6 - { manufacturerId = 0x0371, productId = 0x0018 }, -- MultiSensor 7 -} - -local function can_handle_aeotec_multisensor(opts, self, device, ...) - for _, fingerprint in ipairs(AEOTEC_MULTISENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - local subdriver = require("aeotec-multisensor") - return true, subdriver - end - end - return false -end - local function notification_report_handler(self, device, cmd) local event if cmd.args.notification_type == Notification.notification_type.POWER_MANAGEMENT then @@ -61,12 +35,10 @@ local aeotec_multisensor = { [Notification.REPORT] = notification_report_handler } }, - sub_drivers = { - require("aeotec-multisensor/multisensor-6"), - require("aeotec-multisensor/multisensor-7") - }, + sub_drivers = require("aeotec-multisensor.sub_drivers"), NAME = "aeotec multisensor", - can_handle = can_handle_aeotec_multisensor + can_handle = require("aeotec-multisensor.can_handle"), + shared_device_thread_enabled = true, } return aeotec_multisensor diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/can_handle.lua new file mode 100644 index 0000000000..d86e9c8b3a --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multisensor_6(opts, self, device, ...) +local MULTISENSOR_6_PRODUCT_ID = 0x0064 + if device.zwave_product_id == MULTISENSOR_6_PRODUCT_ID then + return true, require("aeotec-multisensor.multisensor-6") + end + return false +end +return can_handle_multisensor_6 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/init.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/init.lua index 1b9d4d6b97..f8d001cdf4 100644 --- a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-6/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -19,12 +10,8 @@ local cc = require "st.zwave.CommandClass" local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) local WakeUp = (require "st.zwave.CommandClass.WakeUp")({version = 2}) -local MULTISENSOR_6_PRODUCT_ID = 0x0064 local PREFERENCE_NUM = 9 -local function can_handle_multisensor_6(opts, self, device, ...) - return device.zwave_product_id == MULTISENSOR_6_PRODUCT_ID -end local function wakeup_notification(driver, device, cmd) --Note sending WakeUpIntervalGet the first time a device wakes up will happen by default in Lua libs 0.49.x and higher @@ -62,7 +49,8 @@ local multisensor_6 = { } }, NAME = "aeotec multisensor 6", - can_handle = can_handle_multisensor_6 + can_handle = require("aeotec-multisensor.multisensor-6.can_handle"), + shared_device_thread_enabled = true, } return multisensor_6 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/can_handle.lua new file mode 100644 index 0000000000..f109d0e31c --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multisensor_7(opts, self, device, ...) + local MULTISENSOR_7_PRODUCT_ID = 0x0018 + if device.zwave_product_id == MULTISENSOR_7_PRODUCT_ID then + return true, require("aeotec-multisensor.multisensor-7") + end + return false +end + +return can_handle_multisensor_7 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/init.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/init.lua index 2d2bf4e36e..d97d65759c 100644 --- a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/multisensor-7/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -19,13 +10,8 @@ local cc = require "st.zwave.CommandClass" local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 2 }) -local MULTISENSOR_7_PRODUCT_ID = 0x0018 local PREFERENCE_NUM = 10 -local function can_handle_multisensor_7(opts, self, device, ...) - return device.zwave_product_id == MULTISENSOR_7_PRODUCT_ID -end - local function wakeup_notification(driver, device, cmd) --Note sending WakeUpIntervalGet the first time a device wakes up will happen by default in Lua libs 0.49.x and higher --This is done to help the hub correctly set the checkInterval for migrated devices. @@ -62,7 +48,8 @@ local multisensor_7 = { } }, NAME = "aeotec multisensor 7", - can_handle = can_handle_multisensor_7 + can_handle = require("aeotec-multisensor.multisensor-7.can_handle"), + shared_device_thread_enabled = true, } return multisensor_7 diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/sub_drivers.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/sub_drivers.lua new file mode 100644 index 0000000000..396f53fe86 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-multisensor/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("aeotec-multisensor/multisensor-6"), + lazy_load_if_possible("aeotec-multisensor/multisensor-7"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/can_handle.lua new file mode 100644 index 0000000000..1b87febb74 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zwave_water_temp_humidity_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("aeotec-water-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subdriver = require("aeotec-water-sensor") + return true, subdriver + end + end + return false +end + +return can_handle_zwave_water_temp_humidity_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/fingerprints.lua new file mode 100644 index 0000000000..423d87754e --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_WATER_TEMP_HUMIDITY_FINGERPRINTS = { + { manufacturerId = 0x0371, productType = 0x0002, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro EU + { manufacturerId = 0x0371, productType = 0x0102, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro US + { manufacturerId = 0x0371, productType = 0x0202, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro AU + { manufacturerId = 0x0371, productId = 0x0012 } -- Aeotec Water Sensor 7 +} + +return ZWAVE_WATER_TEMP_HUMIDITY_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/init.lua index 9d883ea3c2..509e38ac9e 100644 --- a/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/aeotec-water-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,23 +9,8 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local ZWAVE_WATER_TEMP_HUMIDITY_FINGERPRINTS = { - { manufacturerId = 0x0371, productType = 0x0002, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro EU - { manufacturerId = 0x0371, productType = 0x0102, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro US - { manufacturerId = 0x0371, productType = 0x0202, productId = 0x0013 }, -- Aeotec Water Sensor 7 Pro AU - { manufacturerId = 0x0371, productId = 0x0012 } -- Aeotec Water Sensor 7 -} --- Determine whether the passed device is zwave water temperature humidiry sensor -local function can_handle_zwave_water_temp_humidity_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_WATER_TEMP_HUMIDITY_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - local subdriver = require("aeotec-water-sensor") - return true, subdriver - end - end - return false -end --- Default handler for notification command class reports --- @@ -68,7 +44,8 @@ local zwave_water_temp_humidity_sensor = { }, }, NAME = "zwave water temp humidity sensor", - can_handle = can_handle_zwave_water_temp_humidity_sensor + can_handle = require("aeotec-water-sensor.can_handle"), + shared_device_thread_enabled = true, } return zwave_water_temp_humidity_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-sensor/src/apiv6_bugfix/init.lua deleted file mode 100644 index 322333d565..0000000000 --- a/drivers/SmartThings/zwave-sensor/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,46 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - --- doing refresh would cause incorrect state for device, see comments in wakeup-no-poll -local NORTEK_FP = {mfr = 0x014F, prod = 0x2001, model = 0x0102} -- NorTek open/close sensor -local POPP_THERMOSTAT_FP = {mfr = 0x0002, prod = 0x0115, model = 0xA010} --Popp thermostat -local AEOTEC_MULTISENSOR_6_FP = {mfr = 0x0086, model = 0x0064} --Aeotec multisensor 6 -local AEOTEC_MULTISENSOR_7_FP = {mfr = 0x0371, model = 0x0018} --Aeotec multisensor 7 -local ENERWAVE_MOTION_FP = {mfr = 0x011A} --Enerwave motion sensor -local HOMESEER_MULTI_SENSOR_FP = {mfr = 0x001E, prod = 0x0002, model = 0x0001} -- Homeseer multi sensor HSM100 -local SENSATIVE_STRIP_FP = {mfr = 0x019A, model = 0x000A} -local FPS = {NORTEK_FP, POPP_THERMOSTAT_FP, - AEOTEC_MULTISENSOR_6_FP, AEOTEC_MULTISENSOR_7_FP, - ENERWAVE_MOTION_FP, HOMESEER_MULTI_SENSOR_FP, SENSATIVE_STRIP_FP} - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - if version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION then - - for _, fp in ipairs(FPS) do - if device:id_match(fp.mfr, fp.prod, fp.model) then return false end - end - local subdriver = require("apiv6_bugfix") - return true, subdriver - else - return false - end -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-sensor/src/configurations.lua b/drivers/SmartThings/zwave-sensor/src/configurations.lua index 89c89428fa..0a3c62ead8 100644 --- a/drivers/SmartThings/zwave-sensor/src/configurations.lua +++ b/drivers/SmartThings/zwave-sensor/src/configurations.lua @@ -1,17 +1,8 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) --- @type st.zwave.CommandClass.Association @@ -345,6 +336,20 @@ local devices = { ASSOCIATION = { {grouping_identifier = 1} } + }, + ZOOZ_ZSE42_WATER_LEAK = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x7000, + product_ids = 0xE002 + }, + BATTERY = { + quantity = 1, + type = "CR2450" + }, + WAKE_UP = { + { seconds = 21600 } --6 hours + } } } local configurations = {} @@ -376,6 +381,11 @@ configurations.initial_configuration = function(driver, device) device:send(WakeUp:IntervalSet({seconds = value.seconds, node_id = _node_id})) end end + local battery = configurations.get_device_battery(device) + if battery ~= nil then + device:emit_event(capabilities.battery.quantity({ value = battery.quantity or 1 })) + device:emit_event(capabilities.battery.type({ value = battery.type or "Unspecified" })) + end end configurations.get_device_configuration = function(zw_device) @@ -426,4 +436,16 @@ configurations.get_device_wake_up = function(zw_device) return nil end +configurations.get_device_battery = function(zw_device) + for _, device in pairs(devices) do + if zw_device:id_match( + device.MATCHING_MATRIX.mfrs, + device.MATCHING_MATRIX.product_types, + device.MATCHING_MATRIX.product_ids) then + return device.BATTERY + end + end + return nil +end + return configurations diff --git a/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/can_handle.lua new file mode 100644 index 0000000000..8ab0bac6bc --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_enerwave_motion_sensor(opts, driver, device, cmd, ...) + local ENERWAVE_MFR = 0x011A + if device.zwave_manufacturer_id == ENERWAVE_MFR then + local subdriver = require("enerwave-motion-sensor") + return true, subdriver + else return false end +end + +return can_handle_enerwave_motion_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/init.lua index 6fb712e3b0..53ef77c4be 100644 --- a/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/enerwave-motion-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,15 +10,6 @@ local Association = (require "st.zwave.CommandClass.Association")({version=2}) --- @type st.zwave.CommandClass.WakeUp local WakeUp = (require "st.zwave.CommandClass.WakeUp")({version=1}) -local ENERWAVE_MFR = 0x011A - -local function can_handle_enerwave_motion_sensor(opts, driver, device, cmd, ...) - if device.zwave_manufacturer_id == ENERWAVE_MFR then - local subdriver = require("enerwave-motion-sensor") - return true, subdriver - else return false end -end - local function wakeup_notification(driver, device, cmd) --Note sending WakeUpIntervalGet the first time a device wakes up will happen by default in Lua libs 0.49.x and higher --This is done to help the hub correctly set the checkInterval for migrated devices. @@ -58,7 +39,8 @@ local enerwave_motion_sensor = { doConfigure = do_configure }, NAME = "enerwave_motion_sensor", - can_handle = can_handle_enerwave_motion_sensor + can_handle = require("enerwave-motion-sensor.can_handle"), + shared_device_thread_enabled = true, } return enerwave_motion_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/can_handle.lua new file mode 100644 index 0000000000..c9fd2eafd1 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_everspring_motion_light(opts, driver, device, ...) + local EVERSPRING_MOTION_LIGHT_FINGERPRINT = { mfr = 0x0060, prod = 0x0012, model = 0x0001 } + if device:id_match( + EVERSPRING_MOTION_LIGHT_FINGERPRINT.mfr, + EVERSPRING_MOTION_LIGHT_FINGERPRINT.prod, + EVERSPRING_MOTION_LIGHT_FINGERPRINT.model + ) then + local subdriver = require("everspring-motion-light-sensor") + return true, subdriver + end + return false +end + +return can_handle_everspring_motion_light diff --git a/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/init.lua index 1b11aadabe..8baa3756d6 100644 --- a/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/everspring-motion-light-sensor/init.lua @@ -1,34 +1,12 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=2,strict=true}) local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({version=2}) -local EVERSPRING_MOTION_LIGHT_FINGERPRINT = { mfr = 0x0060, prod = 0x0012, model = 0x0001 } - -local function can_handle_everspring_motion_light(opts, driver, device, ...) - if device:id_match( - EVERSPRING_MOTION_LIGHT_FINGERPRINT.mfr, - EVERSPRING_MOTION_LIGHT_FINGERPRINT.prod, - EVERSPRING_MOTION_LIGHT_FINGERPRINT.model - ) then - local subdriver = require("everspring-motion-light-sensor") - return true, subdriver - else return false end -end - local function device_added(driver, device) device:emit_event(capabilities.motionSensor.motion.inactive()) device:send(SwitchBinary:Get({})) @@ -40,7 +18,7 @@ local everspring_motion_light = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle_everspring_motion_light + can_handle = require("everspring-motion-light-sensor.can_handle"), } return everspring_motion_light diff --git a/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/can_handle.lua new file mode 100644 index 0000000000..5596894fbb --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ezmultipli_multipurpose_sensor(opts, driver, device, ...) + local EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS = { manufacturerId = 0x001E, productType = 0x0004, productId = 0x0001 } + if device:id_match(EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.manufacturerId, + EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.productType, + EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.productId) then + local subdriver = require("ezmultipli-multipurpose-sensor") + return true, subdriver + end + return false +end + +return can_handle_ezmultipli_multipurpose_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/init.lua index 1e4b3bf0ce..64f34ffd96 100644 --- a/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/ezmultipli-multipurpose-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.utils @@ -28,17 +19,6 @@ local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=2}) local CAP_CACHE_KEY = "st.capabilities." .. capabilities.colorControl.ID -local EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS = { manufacturerId = 0x001E, productType = 0x0004, productId = 0x0001 } - -local function can_handle_ezmultipli_multipurpose_sensor(opts, driver, device, ...) - if device:id_match(EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.manufacturerId, - EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.productType, - EZMULTIPLI_MULTIPURPOSE_SENSOR_FINGERPRINTS.productId) then - local subdriver = require("ezmultipli-multipurpose-sensor") - return true, subdriver - else return false end -end - local function basic_report_handler(driver, device, cmd) local event local value = (cmd.args.target_value ~= nil) and cmd.args.target_value or cmd.args.value @@ -102,7 +82,8 @@ local ezmultipli_multipurpose_sensor = { [capabilities.colorControl.commands.setColor.NAME] = set_color } }, - can_handle = can_handle_ezmultipli_multipurpose_sensor + can_handle = require("ezmultipli-multipurpose-sensor.can_handle"), + shared_device_thread_enabled = true, } -return ezmultipli_multipurpose_sensor \ No newline at end of file +return ezmultipli_multipurpose_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/can_handle.lua new file mode 100644 index 0000000000..6cf9ebba98 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_door_window_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("fibaro-door-window-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.prod, fingerprint.productId) then + local subdriver = require("fibaro-door-window-sensor") + return true, subdriver + end + end + return false +end + +return can_handle_fibaro_door_window_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/can_handle.lua new file mode 100644 index 0000000000..992ea8fd9d --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_door_window_sensor_1(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("fibaro-door-window-sensor.fibaro-door-window-sensor-1.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("fibaro-door-window-sensor.fibaro-door-window-sensor-1") + end + end + return false +end + +return can_handle_fibaro_door_window_sensor_1 diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/fingerprints.lua new file mode 100644 index 0000000000..50727133bb --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_DOOR_WINDOW_SENSOR_1_FINGERPRINTS = { + { manufacturerId = 0x010F, prod = 0x0501, productId = 0x1002 } +} + +return FIBARO_DOOR_WINDOW_SENSOR_1_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/init.lua index 698fffcceb..abe2a8b6dc 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-1/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -21,19 +10,6 @@ local SensorAlarm = (require "st.zwave.CommandClass.SensorAlarm")({ version = 1 local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 1 }) local configurationsMap = require "configurations" -local FIBARO_DOOR_WINDOW_SENSOR_1_FINGERPRINTS = { - { manufacturerId = 0x010F, prod = 0x0501, productId = 0x1002 } -} - -local function can_handle_fibaro_door_window_sensor_1(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(FIBARO_DOOR_WINDOW_SENSOR_1_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function sensor_alarm_report_handler(driver, device, cmd) if (cmd.args.sensor_state == SensorAlarm.sensor_state.ALARM) then device:emit_event(capabilities.tamperAlert.tamper.detected()) @@ -92,7 +68,8 @@ local fibaro_door_window_sensor_1 = { [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh }, - can_handle = can_handle_fibaro_door_window_sensor_1 + can_handle = require("fibaro-door-window-sensor.fibaro-door-window-sensor-1.can_handle"), + shared_device_thread_enabled = true, } return fibaro_door_window_sensor_1 diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/can_handle.lua new file mode 100644 index 0000000000..4493496f94 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_door_window_sensor_2(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("fibaro-door-window-sensor.fibaro-door-window-sensor-2.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("fibaro-door-window-sensor.fibaro-door-window-sensor-2") + end + end + return false +end + +return can_handle_fibaro_door_window_sensor_2 diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/fingerprints.lua new file mode 100644 index 0000000000..6103c107d1 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_DOOR_WINDOW_SENSOR_2_FINGERPRINTS = { + { manufacturerId = 0x010F, productType = 0x0702, productId = 0x1000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / Europe + { manufacturerId = 0x010F, productType = 0x0702, productId = 0x2000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / NA + { manufacturerId = 0x010F, productType = 0x0702, productId = 0x3000 } -- Fibaro Open/Closed Sensor 2 (FGDW-002) / ANZ +} + +return FIBARO_DOOR_WINDOW_SENSOR_2_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/init.lua index 16c5ec2017..a1f8ccee82 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fibaro-door-window-sensor-2/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,21 +7,6 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Alarm local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 2 }) -local FIBARO_DOOR_WINDOW_SENSOR_2_FINGERPRINTS = { - { manufacturerId = 0x010F, productType = 0x0702, productId = 0x1000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / Europe - { manufacturerId = 0x010F, productType = 0x0702, productId = 0x2000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / NA - { manufacturerId = 0x010F, productType = 0x0702, productId = 0x3000 } -- Fibaro Open/Closed Sensor 2 (FGDW-002) / ANZ -} - -local function can_handle_fibaro_door_window_sensor_2(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(FIBARO_DOOR_WINDOW_SENSOR_2_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function emit_event_if_latest_state_missing(device, component, capability, attribute_name, value) if device:get_latest_state(component, capability.ID, attribute_name) == nil then device:emit_event(value) @@ -83,7 +57,8 @@ local fibaro_door_window_sensor_2 = { lifecycle_handlers = { added = device_added }, - can_handle = can_handle_fibaro_door_window_sensor_2, + can_handle = require("fibaro-door-window-sensor.fibaro-door-window-sensor-2.can_handle"), + shared_device_thread_enabled = true, } return fibaro_door_window_sensor_2 diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fingerprints.lua new file mode 100644 index 0000000000..699df3f623 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/fingerprints.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_DOOR_WINDOW_SENSOR_FINGERPRINTS = { + { manufacturerId = 0x010F, prod = 0x0700, productId = 0x1000 }, -- Fibaro Open/Closed Sensor (FGK-10x) / Europe + { manufacturerId = 0x010F, prod = 0x0700, productId = 0x2000 }, -- Fibaro Open/Closed Sensor (FGK-10x) / NA + { manufacturerId = 0x010F, prod = 0x0702, productId = 0x1000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / Europe + { manufacturerId = 0x010F, prod = 0x0702, productId = 0x2000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / NA + { manufacturerId = 0x010F, prod = 0x0702, productId = 0x3000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / ANZ + { manufacturerId = 0x010F, prod = 0x0701, productId = 0x2001 }, -- Fibaro Open/Closed Sensor with temperature (FGK-10X) / NA + { manufacturerId = 0x010F, prod = 0x0701, productId = 0x1001 }, -- Fibaro Open/Closed Sensor + { manufacturerId = 0x010F, prod = 0x0501, productId = 0x1002 } -- Fibaro Open/Closed Sensor +} + +return FIBARO_DOOR_WINDOW_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/init.lua index 86cf865348..dc26fcddcd 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local cc = require "st.zwave.CommandClass" local capabilities = require "st.capabilities" @@ -24,27 +13,6 @@ local preferencesMap = require "preferences" local FIBARO_DOOR_WINDOW_SENSOR_WAKEUP_INTERVAL = 21600 --seconds -local FIBARO_DOOR_WINDOW_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x010F, prod = 0x0700, productId = 0x1000 }, -- Fibaro Open/Closed Sensor (FGK-10x) / Europe - { manufacturerId = 0x010F, prod = 0x0700, productId = 0x2000 }, -- Fibaro Open/Closed Sensor (FGK-10x) / NA - { manufacturerId = 0x010F, prod = 0x0702, productId = 0x1000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / Europe - { manufacturerId = 0x010F, prod = 0x0702, productId = 0x2000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / NA - { manufacturerId = 0x010F, prod = 0x0702, productId = 0x3000 }, -- Fibaro Open/Closed Sensor 2 (FGDW-002) / ANZ - { manufacturerId = 0x010F, prod = 0x0701, productId = 0x2001 }, -- Fibaro Open/Closed Sensor with temperature (FGK-10X) / NA - { manufacturerId = 0x010F, prod = 0x0701, productId = 0x1001 }, -- Fibaro Open/Closed Sensor - { manufacturerId = 0x010F, prod = 0x0501, productId = 0x1002 } -- Fibaro Open/Closed Sensor -} - -local function can_handle_fibaro_door_window_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(FIBARO_DOOR_WINDOW_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.prod, fingerprint.productId) then - local subdriver = require("fibaro-door-window-sensor") - return true, subdriver - end - end - return false -end - local function parameterNumberToParameterName(preferences,parameterNumber) for id, parameter in pairs(preferences) do if parameter.parameter_number == parameterNumber then @@ -154,11 +122,9 @@ local fibaro_door_window_sensor = { [capabilities.refresh.commands.refresh.NAME] = do_refresh } }, - sub_drivers = { - require("fibaro-door-window-sensor/fibaro-door-window-sensor-1"), - require("fibaro-door-window-sensor/fibaro-door-window-sensor-2") - }, - can_handle = can_handle_fibaro_door_window_sensor + sub_drivers = require("fibaro-door-window-sensor.sub_drivers"), + can_handle = require("fibaro-door-window-sensor.can_handle"), + shared_device_thread_enabled = true, } return fibaro_door_window_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/sub_drivers.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/sub_drivers.lua new file mode 100644 index 0000000000..0c4ddd4e43 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-door-window-sensor/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("fibaro-door-window-sensor/fibaro-door-window-sensor-1"), + lazy_load_if_possible("fibaro-door-window-sensor/fibaro-door-window-sensor-2"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/can_handle.lua new file mode 100644 index 0000000000..341fbcd6f9 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_flood_sensor(opts, driver, device, ...) + local FIBARO_MFR_ID = 0x010F + local FIBARO_FLOOD_PROD_TYPES = { 0x0000, 0x0B00 } + if device:id_match(FIBARO_MFR_ID, FIBARO_FLOOD_PROD_TYPES, nil) then + local subdriver = require("fibaro-flood-sensor") + return true, subdriver + end + return false +end + +return can_handle_fibaro_flood_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/init.lua index 144be985ae..75fc982511 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-flood-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -26,17 +17,6 @@ local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = local preferences = require "preferences" local configurations = require "configurations" -local FIBARO_MFR_ID = 0x010F -local FIBARO_FLOOD_PROD_TYPES = { 0x0000, 0x0B00 } - -local function can_handle_fibaro_flood_sensor(opts, driver, device, ...) - if device:id_match(FIBARO_MFR_ID, FIBARO_FLOOD_PROD_TYPES, nil) then - local subdriver = require("fibaro-flood-sensor") - return true, subdriver - else return false end -end - - local function basic_set_handler(self, device, cmd) local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value device:emit_event(value == 0xFF and capabilities.waterSensor.water.wet() or capabilities.waterSensor.water.dry()) @@ -96,7 +76,8 @@ local fibaro_flood_sensor = { lifecycle_handlers = { doConfigure = do_configure }, - can_handle = can_handle_fibaro_flood_sensor + can_handle = require("fibaro-flood-sensor.can_handle"), + shared_device_thread_enabled = true, } return fibaro_flood_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/can_handle.lua new file mode 100644 index 0000000000..b184001935 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_motion_sensor(opts, driver, device, ...) + + local FIBARO_MOTION_MFR = 0x010F + local FIBARO_MOTION_PROD = 0x0800 + if device:id_match(FIBARO_MOTION_MFR, FIBARO_MOTION_PROD) then + local subdriver = require("fibaro-motion-sensor") + return true, subdriver + end + return false +end + +return can_handle_fibaro_motion_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/init.lua index ae45f5a27b..e1aa2ccaf8 100644 --- a/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/fibaro-motion-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -18,15 +8,6 @@ local cc = require "st.zwave.CommandClass" local SensorAlarm = (require "st.zwave.CommandClass.SensorAlarm")({ version = 1 }) local capabilities = require "st.capabilities" -local FIBARO_MOTION_MFR = 0x010F -local FIBARO_MOTION_PROD = 0x0800 - -local function can_handle_fibaro_motion_sensor(opts, driver, device, ...) - if device:id_match(FIBARO_MOTION_MFR, FIBARO_MOTION_PROD) then - local subdriver = require("fibaro-motion-sensor") - return true, subdriver - else return false end -end local function sensor_alarm_report(driver, device, cmd) if (cmd.args.sensor_state ~= SensorAlarm.sensor_state.NO_ALARM) then @@ -43,7 +24,8 @@ local fibaro_motion_sensor = { [SensorAlarm.REPORT] = sensor_alarm_report } }, - can_handle = can_handle_fibaro_motion_sensor + can_handle = require("fibaro-motion-sensor.can_handle"), + shared_device_thread_enabled = true, } -return fibaro_motion_sensor \ No newline at end of file +return fibaro_motion_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/firmware-version/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/firmware-version/can_handle.lua new file mode 100644 index 0000000000..3ecdc2baf0 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/firmware-version/can_handle.lua @@ -0,0 +1,21 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +--This sub_driver will populate the currentVersion (firmware) when the firmwareUpdate capability is enabled +local FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x7000, productId = 0xE002 } -- Zooz ZSE42 Water Sensor +} + +return function(opts, driver, device, ...) + if device:supports_capability_by_id(capabilities.firmwareUpdate.ID) then + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subDriver = require("firmware-version") + return true, subDriver + end + end + end + return false +end \ No newline at end of file diff --git a/drivers/SmartThings/zwave-sensor/src/firmware-version/init.lua b/drivers/SmartThings/zwave-sensor/src/firmware-version/init.lua new file mode 100644 index 0000000000..8fe58a7167 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/firmware-version/init.lua @@ -0,0 +1,66 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.Version +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) +--- @type st.zwave.CommandClass.WakeUp +local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) + + +--Runs upstream handlers (ex zwave_handlers) +local function call_parent_handler(handlers, self, device, event, args) + for _, func in ipairs(handlers or {}) do + func(self, device, event, args) + end +end + +--Request version if not populated yet +local function send_version_get(driver, device) + if device:get_latest_state("main", capabilities.firmwareUpdate.ID, capabilities.firmwareUpdate.currentVersion.NAME) == nil then + device:send(Version:Get({})) + end +end + +local function version_report(driver, device, cmd) + local major = cmd.args.application_version + local minor = cmd.args.application_sub_version + local fmtFirmwareVersion = string.format("%d.%02d", major, minor) + device:emit_event(capabilities.firmwareUpdate.currentVersion({ value = fmtFirmwareVersion })) +end + +local function wakeup_notification(driver, device, cmd) + send_version_get(driver, device) + --Call parent WakeUp functions + call_parent_handler(driver.zwave_handlers[cc.WAKE_UP][WakeUp.NOTIFICATION], driver, device, cmd) +end + +local function added_handler(driver, device) + --Call main function + driver.lifecycle_handlers.added(driver, device) + --Extras for this sub_driver + send_version_get(driver, device) +end + +local firmware_version = { + NAME = "firmware_version", + can_handle = require("firmware-version.can_handle"), + + lifecycle_handlers = { + added = added_handler, + }, + zwave_handlers = { + [cc.VERSION] = { + [Version.REPORT] = version_report + }, + [cc.WAKE_UP] = { + [WakeUp.NOTIFICATION] = wakeup_notification + } + }, + shared_device_thread_enabled = true, +} + +return firmware_version diff --git a/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/can_handle.lua new file mode 100644 index 0000000000..e24d7b9cf2 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is glentronics water leak sensor +--- +--- @param driver Driver driver instance +--- @param device Device device isntance +--- @return boolean true if the device proper, else false +local function can_handle_glentronics_water_leak_sensor(opts, driver, device, ...) + local GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS = { manufacturerId = 0x0084, productType = 0x0093, productId = 0x0114 } -- glentronics water leak sensor + if device:id_match( + GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.manufacturerId, + GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.productType, + GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.productId) then + return true, require("glentronics-water-leak-sensor") + end + return false +end + +return can_handle_glentronics_water_leak_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/init.lua index 3dba7351d6..cc6c476658 100644 --- a/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/glentronics-water-leak-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,23 +9,6 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS = { manufacturerId = 0x0084, productType = 0x0093, productId = 0x0114 } -- glentronics water leak sensor - ---- Determine whether the passed device is glentronics water leak sensor ---- ---- @param driver Driver driver instance ---- @param device Device device isntance ---- @return boolean true if the device proper, else false -local function can_handle_glentronics_water_leak_sensor(opts, driver, device, ...) - if device:id_match( - GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.manufacturerId, - GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.productType, - GLENTRONICS_WATER_LEAK_SENSOR_FINGERPRINTS.productId) then - local subdriver = require("glentronics-water-leak-sensor") - return true, subdriver - else return false end -end - local function notification_report_handler(self, device, cmd) local event if cmd.args.notification_type == Notification.notification_type.POWER_MANAGEMENT then @@ -78,7 +52,8 @@ local glentronics_water_leak_sensor = { added = device_added }, NAME = "glentronics water leak sensor", - can_handle = can_handle_glentronics_water_leak_sensor + can_handle = require("glentronics-water-leak-sensor.can_handle"), + shared_device_thread_enabled = true, } return glentronics_water_leak_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/can_handle.lua new file mode 100644 index 0000000000..992c1f7c7f --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/can_handle.lua @@ -0,0 +1,20 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is homeseer multi sensor +--- +--- @param driver Driver driver instance +--- @param device Device device instance +--- @return boolean true if the device proper, else false +local function can_handle_homeseer_multi_sensor(opts, driver, device, ...) + local HOMESEER_MULTI_SENSOR_FINGERPRINTS = { manufacturerId = 0x001E, productType = 0x0002, productId = 0x0001 } -- Homeseer multi sensor HSM100 + if device:id_match( + HOMESEER_MULTI_SENSOR_FINGERPRINTS.manufacturerId, + HOMESEER_MULTI_SENSOR_FINGERPRINTS.productType, + HOMESEER_MULTI_SENSOR_FINGERPRINTS.productId) then + return true, require("homeseer-multi-sensor") + end + return false +end + +return can_handle_homeseer_multi_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/init.lua index 2330f28106..07835ac7d4 100644 --- a/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/homeseer-multi-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -22,23 +13,6 @@ local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({version = 5}) local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1}) -local HOMESEER_MULTI_SENSOR_FINGERPRINTS = { manufacturerId = 0x001E, productType = 0x0002, productId = 0x0001 } -- Homeseer multi sensor HSM100 - ---- Determine whether the passed device is homeseer multi sensor ---- ---- @param driver Driver driver instance ---- @param device Device device instance ---- @return boolean true if the device proper, else false -local function can_handle_homeseer_multi_sensor(opts, driver, device, ...) - if device:id_match( - HOMESEER_MULTI_SENSOR_FINGERPRINTS.manufacturerId, - HOMESEER_MULTI_SENSOR_FINGERPRINTS.productType, - HOMESEER_MULTI_SENSOR_FINGERPRINTS.productId) then - local subdriver = require("homeseer-multi-sensor") - return true, subdriver - else return false end -end - local function basic_set_handler(self, device, cmd) if cmd.args.value ~= nil then device:emit_event(cmd.args.value == 0xFF and capabilities.motionSensor.motion.active() or capabilities.motionSensor.motion.inactive()) @@ -87,7 +61,8 @@ local homeseer_multi_sensor = { init = device_init, }, NAME = "homeseer multi sensor", - can_handle = can_handle_homeseer_multi_sensor + can_handle = require("homeseer-multi-sensor.can_handle"), + shared_device_thread_enabled = true, } return homeseer_multi_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/init.lua b/drivers/SmartThings/zwave-sensor/src/init.lua index d92a09fe56..9ab4d0077d 100644 --- a/drivers/SmartThings/zwave-sensor/src/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -27,19 +16,6 @@ local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) local preferences = require "preferences" local configurations = require "configurations" -local function lazy_load_if_possible(sub_driver_name) - -- gets the current lua libs api version - local version = require "version" - - -- version 9 will include the lazy loading functions - if version.api >= 9 then - return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) - else - return require(sub_driver_name) - end - -end - --- Handle preference changes --- --- @param driver st.zwave.Driver @@ -134,26 +110,7 @@ local driver_template = { capabilities.powerMeter, capabilities.smokeDetector }, - sub_drivers = { - lazy_load_if_possible("zooz-4-in-1-sensor"), - lazy_load_if_possible("vision-motion-detector"), - lazy_load_if_possible("fibaro-flood-sensor"), - lazy_load_if_possible("aeotec-water-sensor"), - lazy_load_if_possible("glentronics-water-leak-sensor"), - lazy_load_if_possible("homeseer-multi-sensor"), - lazy_load_if_possible("fibaro-door-window-sensor"), - lazy_load_if_possible("sensative-strip"), - lazy_load_if_possible("enerwave-motion-sensor"), - lazy_load_if_possible("aeotec-multisensor"), - lazy_load_if_possible("zwave-water-leak-sensor"), - lazy_load_if_possible("everspring-motion-light-sensor"), - lazy_load_if_possible("ezmultipli-multipurpose-sensor"), - lazy_load_if_possible("fibaro-motion-sensor"), - lazy_load_if_possible("v1-contact-event"), - lazy_load_if_possible("timed-tamper-clear"), - lazy_load_if_possible("wakeup-no-poll"), - lazy_load_if_possible("apiv6_bugfix"), - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { added = added_handler, init = device_init, @@ -168,6 +125,7 @@ local driver_template = { [WakeUp.NOTIFICATION] = wakeup_notification } }, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, diff --git a/drivers/SmartThings/zwave-sensor/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-sensor/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-sensor/src/preferences.lua b/drivers/SmartThings/zwave-sensor/src/preferences.lua index 4921a996bf..70293b10fa 100644 --- a/drivers/SmartThings/zwave-sensor/src/preferences.lua +++ b/drivers/SmartThings/zwave-sensor/src/preferences.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) @@ -162,6 +152,19 @@ local devices = { motionNotdetRepT = {parameter_number = 160, size = 2}, }, }, + ZOOZ_ZSE42_WATER_LEAK = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x7000, + product_ids = 0xE002 + }, + PARAMETERS = { + ledIndicator = { parameter_number = 1, size = 1 }, + leakAlertClearDelay = { parameter_number = 2, size = 4 }, + batteryThreshold = { parameter_number = 3, size = 1 }, + lowBatteryAlert = { parameter_number = 4, size = 1 }, + }, + }, } local preferences = {} diff --git a/drivers/SmartThings/zwave-sensor/src/sensative-strip/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/sensative-strip/can_handle.lua new file mode 100644 index 0000000000..9b515bae9f --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/sensative-strip/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_sensative_strip(opts, driver, device, cmd, ...) + local SENSATIVE_MFR = 0x019A + local SENSATIVE_MODEL = 0x000A + if device:id_match(SENSATIVE_MFR, nil, SENSATIVE_MODEL) then + local subdriver = require("sensative-strip") + return true, subdriver + end + return false +end + +return can_handle_sensative_strip diff --git a/drivers/SmartThings/zwave-sensor/src/sensative-strip/init.lua b/drivers/SmartThings/zwave-sensor/src/sensative-strip/init.lua index 73f1cb8459..89085c00c9 100644 --- a/drivers/SmartThings/zwave-sensor/src/sensative-strip/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/sensative-strip/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -19,20 +9,11 @@ local Configuration = (require "st.zwave.CommandClass.Configuration")({ version --- @type st.zwave.CommandClass.WakeUp local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) -local SENSATIVE_MFR = 0x019A -local SENSATIVE_MODEL = 0x000A local LEAKAGE_ALARM_PARAM = 12 local LEAKAGE_ALARM_OFF = 0 local SENSATIVE_COMFORT_PROFILE = "illuminance-temperature" local CONFIG_REPORT_RECEIVED = "configReportReceived" -local function can_handle_sensative_strip(opts, driver, device, cmd, ...) - if device:id_match(SENSATIVE_MFR, nil, SENSATIVE_MODEL) then - local subdriver = require("sensative-strip") - return true, subdriver - else return false end -end - local function configuration_report(driver, device, cmd) local parameter_number = cmd.args.parameter_number local configuration_value = cmd.args.configuration_value @@ -75,7 +56,8 @@ local sensative_strip = { doConfigure = do_configure }, NAME = "sensative_strip", - can_handle = can_handle_sensative_strip + can_handle = require("sensative-strip.can_handle"), + shared_device_thread_enabled = true, } return sensative_strip diff --git a/drivers/SmartThings/zwave-sensor/src/sub_drivers.lua b/drivers/SmartThings/zwave-sensor/src/sub_drivers.lua new file mode 100644 index 0000000000..9500731597 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/sub_drivers.lua @@ -0,0 +1,25 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require("lazy_load_subdriver") + +return { + lazy_load_if_possible("zooz-4-in-1-sensor"), + lazy_load_if_possible("vision-motion-detector"), + lazy_load_if_possible("fibaro-flood-sensor"), + lazy_load_if_possible("aeotec-water-sensor"), + lazy_load_if_possible("glentronics-water-leak-sensor"), + lazy_load_if_possible("homeseer-multi-sensor"), + lazy_load_if_possible("fibaro-door-window-sensor"), + lazy_load_if_possible("sensative-strip"), + lazy_load_if_possible("enerwave-motion-sensor"), + lazy_load_if_possible("aeotec-multisensor"), + lazy_load_if_possible("zwave-water-leak-sensor"), + lazy_load_if_possible("everspring-motion-light-sensor"), + lazy_load_if_possible("ezmultipli-multipurpose-sensor"), + lazy_load_if_possible("fibaro-motion-sensor"), + lazy_load_if_possible("v1-contact-event"), + lazy_load_if_possible("timed-tamper-clear"), + lazy_load_if_possible("wakeup-no-poll"), + lazy_load_if_possible("firmware-version"), +} diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeon_multisensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeon_multisensor.lua index 2d83fd6e25..649855f9c3 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeon_multisensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeon_multisensor.lua @@ -1,21 +1,13 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" local zw_test_utils = require "integration_test.zwave_test_utils" +local capabilities = require "st.capabilities" local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 1 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 5 }) local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) @@ -89,7 +81,35 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Notification HOME_SECURITY MOTION_DETECTION should be handled as motion active", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_sensor.id, + zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.HOME_SECURITY, + event = Notification.event.home_security.MOTION_DETECTION + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_6.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_6.lua index 02c2adf751..9e7a02851b 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_6.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_6.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -116,7 +106,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ULTRAVIOLET}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -164,7 +157,10 @@ test.register_coroutine_test( )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Configuration value should be updated and device refreshed, when wakeup notification received", @@ -233,7 +229,10 @@ test.register_coroutine_test( Configuration:Get({parameter_number = 9}) )) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -252,6 +251,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.ultravioletIndex.ultravioletIndex({value = 10})) } + }, + { + min_api_version = 14 } ) @@ -284,6 +286,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.powerSource.powerSource.dc()) } + }, + { + min_api_version = 14 } ) @@ -316,6 +321,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 14 } ) @@ -335,6 +343,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -356,7 +367,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -389,7 +403,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -410,7 +427,35 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) ) - end + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Notification HOME_SECURITY MOTION_DETECTION should be handled as motion active", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_sensor.id, + zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.HOME_SECURITY, + event = Notification.event.home_security.MOTION_DETECTION + })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) + } + }, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_7.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_7.lua index 7fd57e42b2..648fb9a5ad 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_7.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_7.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -118,7 +108,10 @@ test.register_coroutine_test( )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -164,7 +157,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ULTRAVIOLET}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Configuration value should be updated and device refreshed, when wakeup notification received", @@ -231,7 +227,10 @@ test.register_coroutine_test( mock_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ULTRAVIOLET}) )) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -250,6 +249,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.ultravioletIndex.ultravioletIndex({value = 10})) } + }, + { + min_api_version = 14 } ) @@ -282,6 +284,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.powerSource.powerSource.dc()) } + }, + { + min_api_version = 14 } ) @@ -314,6 +319,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_gen5.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_gen5.lua index 3b73ff17c3..25bba0e7b8 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_gen5.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_multisensor_gen5.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -106,7 +96,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor.lua index 8af011f51f..62ab74cfde 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -86,7 +76,10 @@ test.register_coroutine_test( Association:Set({grouping_identifier = 4, node_ids = {}}) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -102,6 +95,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -118,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -137,6 +136,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -156,6 +158,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -175,6 +180,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -194,6 +202,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -210,6 +221,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -229,6 +243,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -248,6 +265,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -267,6 +287,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -286,6 +309,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor_7.lua b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor_7.lua index a5f44ff12e..26e61fae14 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor_7.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_aeotec_water_sensor_7.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -60,6 +50,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -79,6 +72,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -98,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -117,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -136,6 +138,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -161,7 +166,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(10) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_enerwave_motion_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_enerwave_motion_sensor.lua index a90655669d..73af4449dc 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_enerwave_motion_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_enerwave_motion_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -61,7 +51,10 @@ test.register_coroutine_test( Association:Set({grouping_identifier = 1, node_ids = {}}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -81,7 +74,10 @@ test.register_coroutine_test( mock_sensor, Battery:Get({}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -100,7 +96,10 @@ test.register_coroutine_test( WakeUp:IntervalGet({}) )) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everpsring_sp817.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everpsring_sp817.lua index 45e0672c11..df57046912 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everpsring_sp817.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everpsring_sp817.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -59,7 +49,10 @@ test.register_coroutine_test( Battery:Get({}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -93,7 +86,10 @@ test.register_coroutine_test( mock_sensor, Battery:Get({}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_PIR_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_PIR_sensor.lua index 1cb2735a31..9efe546309 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_PIR_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_PIR_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -66,6 +56,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -86,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -110,7 +106,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(10) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -129,6 +128,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -149,6 +151,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -196,7 +201,10 @@ test.register_coroutine_test( mock_sensor, Battery:Get({}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_ST814.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_ST814.lua index 097016fc81..1bef4fb686 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_ST814.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_ST814.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -58,7 +48,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 8, size = 1, configuration_value = 5}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_illuminance_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_illuminance_sensor.lua index 04103db0db..dfce8285d9 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_illuminance_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_illuminance_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -62,7 +52,10 @@ test.register_coroutine_test( Battery:Get({}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_motion_light_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_motion_light_sensor.lua index 17a627a736..0babb02dc1 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_everspring_motion_light_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_everspring_motion_light_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -75,7 +65,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_ezmultipli_multipurpose_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_ezmultipli_multipurpose_sensor.lua index 74b6d6196c..08289dd301 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_ezmultipli_multipurpose_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_ezmultipli_multipurpose_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -60,6 +50,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -86,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -121,7 +117,11 @@ do mock_device, SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) )) - end + end, + { + min_api_version = 14 + } + ) end @@ -157,7 +157,11 @@ do mock_device, SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) )) - end + end, + { + min_api_version = 14 + } + ) end @@ -287,7 +291,10 @@ test.register_coroutine_test( ) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor.lua index 248c22ab60..386891a9cc 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -82,7 +72,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -104,7 +95,10 @@ test.register_coroutine_test( SensorBinary:Get({}) )) mock_fibaro_door_window_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -120,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -139,6 +136,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -158,6 +158,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -178,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -197,6 +203,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_1.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_1.lua index 6904f318a9..8e9c936237 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_1.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_1.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -130,7 +120,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -217,7 +208,10 @@ test.register_coroutine_test( Association:Remove({grouping_identifier = 1, node_ids = {}}) )) mock_fibaro_door_window_sensor1:expect_metadata_update({provisioning_state = "PROVISIONED"}) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -233,6 +227,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -252,6 +249,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -271,6 +271,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -295,6 +298,9 @@ test.register_message_test( { device_uuid = mock_fibaro_door_window_sensor1.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -319,6 +325,9 @@ test.register_message_test( { device_uuid = mock_fibaro_door_window_sensor1.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -338,6 +347,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -356,6 +368,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor1:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -384,6 +399,9 @@ test.register_message_test( { device_uuid = mock_fibaro_door_window_sensor1.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -411,6 +429,9 @@ test.register_message_test( { device_uuid = mock_fibaro_door_window_sensor1.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_2.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_2.lua index bc78bd02ca..7a1ddb417e 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_2.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_2.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -86,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -105,6 +101,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -124,6 +123,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -143,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) } + }, + { + min_api_version = 14 } ) @@ -162,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 14 } ) @@ -181,6 +189,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 14 } ) @@ -295,7 +306,10 @@ test.register_coroutine_test( mock_fibaro_door_window_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -333,7 +347,10 @@ test.register_coroutine_test( mock_fibaro_door_window_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) )) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -366,6 +383,9 @@ test.register_message_test( direction = "receive", message = {mock_fibaro_door_window_sensor.id, "added"} } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_with_temperature.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_with_temperature.lua index 2b27d66868..0b970eded6 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_with_temperature.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_door_window_sensor_with_temperature.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -94,7 +84,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -120,7 +111,10 @@ test.register_coroutine_test( SensorBinary:Get({}) )) mock_fibaro_door_window_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -136,6 +130,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -155,6 +152,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -174,6 +174,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -194,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -213,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_door_window_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -241,6 +250,9 @@ test.register_message_test( { device_uuid = mock_fibaro_door_window_sensor.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -268,6 +280,9 @@ test.register_message_test( { device_uuid = mock_fibaro_door_window_sensor.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -542,7 +557,10 @@ test.register_coroutine_test( mock_fibaro_door_window_sensor.id, Configuration:Report({ parameter_number = 56, configuration_value = 40 }) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor.lua index 7a9743a69e..7de8a345b3 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -20,6 +10,8 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) local SensorAlarm = (require "st.zwave.CommandClass.SensorAlarm")({ version = 1 }) local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({ version = 2 }) local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 5 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) local t_utils = require "integration_test.utils" local sensor_endpoints = { @@ -60,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -76,6 +71,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -94,6 +92,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -112,6 +113,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -139,6 +143,9 @@ test.register_message_test( { device_uuid = mock_sensor.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -158,6 +165,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -177,6 +187,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -201,7 +214,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(30) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -225,7 +241,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(30) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -249,7 +268,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(30) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -273,8 +295,41 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(30) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 14 + } ) +test.register_coroutine_test( + "doConfigure should call initial_configuration and preferences for non-wakeup device", + function () + test.socket.zwave:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_sensor.id, "doConfigure" }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_sensor, + Configuration:Set({ parameter_number = 74, configuration_value = 3, size = 1 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_sensor, + Association:Set({ grouping_identifier = 2, node_ids = {} }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_sensor, + Association:Set({ grouping_identifier = 3, node_ids = {} }) + ) + ) + mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 14 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor_zw5.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor_zw5.lua index 45cde29b8f..dc47194f8c 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor_zw5.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_flood_sensor_zw5.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -90,7 +80,10 @@ test.register_coroutine_test( mock_sensor, Configuration:Set({parameter_number = 77, size = 1, configuration_value = 2}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor.lua index 24a7f31eaf..3a2f5b3644 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -95,7 +85,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.LUMINANCE, scale = SensorMultilevel.scale.luminance.LUX}) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -111,6 +104,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -135,6 +131,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -154,6 +153,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -173,6 +175,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active()) } + }, + { + min_api_version = 14 } ) @@ -192,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.inactive()) } + }, + { + min_api_version = 14 } ) @@ -211,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.accelerationSensor.acceleration.active()) } + }, + { + min_api_version = 14 } ) @@ -231,6 +242,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 14 } ) @@ -250,6 +264,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -269,6 +286,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -293,6 +313,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -321,6 +344,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -348,6 +374,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor_zw5.lua b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor_zw5.lua index e514a2f2e5..22f9df61db 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor_zw5.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_fibaro_motion_sensor_zw5.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -77,7 +67,10 @@ test.register_coroutine_test( capabilities.threeAxis.threeAxis({value = {200, 200, 400}, unit = 'mG'}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -213,7 +206,10 @@ test.register_coroutine_test( mock_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.ACCELERATION_Z_AXIS, scale = SensorMultilevel.scale.acceleration_z_axis.METERS_PER_SQUARE_SECOND}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_firmware_version.lua b/drivers/SmartThings/zwave-sensor/src/test/test_firmware_version.lua new file mode 100644 index 0000000000..e85f290f11 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/test/test_firmware_version.lua @@ -0,0 +1,132 @@ +-- 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. + +local test = require "integration_test" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.Version +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) +--- @type st.zwave.CommandClass.WakeUp +local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) +--- @type st.zwave.CommandClass.Battery +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) + +local sensor_endpoints = { + { + command_classes = { + { value = cc.WAKE_UP }, + { value = cc.BATTERY }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("zooz-zse42-water.yml"), + zwave_endpoints = sensor_endpoints, + zwave_manufacturer_id = 0x027A, + zwave_product_type = 0x7000, + zwave_product_id = 0xE002, +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +test.register_message_test( + "Wakeup notification should not poll binary sensor if device has contact state", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(WakeUp:Notification({ })) } + }, + --This is sent by the sub-driver + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + Version:Get({}) + ) + }, + --This is sent by the main driver + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + WakeUp:IntervalGetV1({}) + ) + }, + --This is sent by the main driver + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + Battery:Get({ }) + ) + }, + }, + { + inner_block_ordering = "relaxed", + min_api_version = 14 + } +) + +test.register_message_test( + "Version:Report should emit firmwareUpdate.currentVersion", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Version:Report({ + application_version = 1, + application_sub_version = 5, + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.firmwareUpdate.currentVersion({ value = "1.05" })) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "added lifecycle event should emit initial state and request firmware version", + function () + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, Version:Get({})) + ) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() + diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_generic_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_generic_sensor.lua index 643c69dd1f..9f8019e240 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_generic_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_generic_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -71,6 +61,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.atmosphericPressureMeasurement.atmosphericPressure({ value = 101.3, unit = "kPa" })) } + }, + { + min_api_version = 14 } ) @@ -91,6 +84,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.atmosphericPressureMeasurement.atmosphericPressure({ value = 30.13 * KILO_PASCAL_PER_INCH_OF_MERCURY, unit = "kPa" })) } + }, + { + min_api_version = 14 } ) @@ -110,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.bodyWeightMeasurement.bodyWeightMeasurement({ value = 60, unit = "kg" })) } + }, + { + min_api_version = 14 } ) @@ -130,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.bodyWeightMeasurement.bodyWeightMeasurement({ value = 120, unit = "lbs" })) } + }, + { + min_api_version = 14 } ) @@ -150,6 +152,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 700, unit = "lux" })) } + }, + { + min_api_version = 14 } ) @@ -169,6 +174,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 70 })) } + }, + { + min_api_version = 14 } ) @@ -197,6 +205,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -225,6 +236,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -244,6 +258,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.voltageMeasurement.voltage({ value = 5, unit = "V" })) } + }, + { + min_api_version = 14 } ) @@ -264,6 +281,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.voltageMeasurement.voltage({ value = 0.005, unit = "V" })) } + }, + { + min_api_version = 14 } ) @@ -291,6 +311,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -319,6 +342,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -338,6 +364,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 50, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -357,6 +386,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 50, unit = "kVAh" })) } + }, + { + min_api_version = 14 } ) @@ -378,6 +410,9 @@ test.register_message_test( direction = "send", message = zw_test_utils.zwave_test_build_send_command(mock_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS })) } + }, + { + min_api_version = 14 } ) @@ -419,6 +454,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -460,6 +498,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -479,6 +520,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -498,6 +542,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -522,6 +569,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -546,6 +596,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -637,6 +690,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -728,6 +784,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) }, + }, + { + min_api_version = 14 } ) @@ -747,6 +806,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -766,6 +828,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -785,6 +850,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) } + }, + { + min_api_version = 14 } ) @@ -804,6 +872,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -823,6 +894,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -842,6 +916,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -861,6 +938,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -880,6 +960,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -899,6 +982,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -918,6 +1004,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -937,6 +1026,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -956,6 +1048,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -975,6 +1070,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -1006,7 +1104,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1026,6 +1125,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -1045,6 +1147,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -1064,6 +1169,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -1090,7 +1198,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1110,6 +1219,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -1129,6 +1241,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -1148,6 +1263,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -1167,6 +1285,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -1186,6 +1307,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -1205,6 +1329,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -1224,6 +1351,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -1243,6 +1373,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -1262,6 +1395,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -1281,6 +1417,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -1300,6 +1439,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -1319,6 +1461,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -1338,6 +1483,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -1356,6 +1504,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) } + }, + { + min_api_version = 14 } ) @@ -1374,6 +1525,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -1393,7 +1547,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(10) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, Battery:Get({}))) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -1412,6 +1569,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -1431,6 +1591,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(0)) } + }, + { + min_api_version = 14 } ) @@ -1548,10 +1711,19 @@ test.register_message_test( mock_device, Meter:Get({scale = 0}) ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_device, + Meter:Get({scale = 4}) + ) } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 19 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_glentronics_water_leak_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_glentronics_water_leak_sensor.lua index 747c4cbce1..52a3a50b9a 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_glentronics_water_leak_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_glentronics_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -78,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 14 } ) @@ -100,6 +93,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.battery()) } + }, + { + min_api_version = 14 } ) @@ -122,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerSource.powerSource.mains()) } + }, + { + min_api_version = 14 } ) @@ -144,6 +143,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -166,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(100)) } + }, + { + min_api_version = 14 } ) @@ -189,6 +194,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -212,6 +220,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_homeseer_multi_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_homeseer_multi_sensor.lua index f57bfe7950..dd9101edf7 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_homeseer_multi_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_homeseer_multi_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -59,6 +49,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -75,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -94,6 +90,9 @@ test.register_message_test( WakeUp:IntervalSet({node_id = 0x00, seconds = 1200}) ) } + }, + { + min_api_version = 14 } ) @@ -136,7 +135,10 @@ test.register_coroutine_test( mock_sensor, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.LUMINANCE}, {dst_channels={2}}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Receiving wakeup notification should generate proper messages", @@ -164,6 +166,9 @@ test.register_coroutine_test( mock_sensor, Battery:Get({}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_no_wakeup_poll.lua b/drivers/SmartThings/zwave-sensor/src/test/test_no_wakeup_poll.lua index 9818f1422c..c9eb716c3b 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_no_wakeup_poll.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_no_wakeup_poll.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -109,8 +99,9 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_sensative_strip.lua b/drivers/SmartThings/zwave-sensor/src/test/test_sensative_strip.lua index 873909df23..0071f3f09a 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_sensative_strip.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_sensative_strip.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -69,7 +59,10 @@ test.register_coroutine_test( Configuration:Get({ parameter_number = 12 }) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -77,7 +70,10 @@ test.register_coroutine_test( function() test.socket.zwave:__queue_receive({mock_sensor.id, Configuration:Report( { configuration_value = 0x00, parameter_number = 12 } )}) mock_sensor:expect_metadata_update({ profile = "illuminance-temperature" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -96,7 +92,10 @@ test.register_coroutine_test( test.socket.zwave:__queue_receive({mock_sensor.id, Configuration:Report( { configuration_value = 0x00, parameter_number = 12 } )}) mock_sensor:expect_metadata_update({ profile = "illuminance-temperature" }) test.socket.zwave:__queue_receive({mock_sensor.id, WakeUp:Notification({})}) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_smartthings_water_leak_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_smartthings_water_leak_sensor.lua index 2321b5bd09..cf7d08ebdf 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_smartthings_water_leak_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_smartthings_water_leak_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,7 +57,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, scale = SensorMultilevel.scale.temperature.CELSIUS }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -86,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -105,6 +101,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -124,6 +123,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -143,6 +145,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -162,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -181,6 +189,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -209,6 +220,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -237,6 +251,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -253,6 +270,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -269,6 +289,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -285,6 +308,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_v1_contact_event.lua b/drivers/SmartThings/zwave-sensor/src/test/test_v1_contact_event.lua index 866508e3d6..0db72f1b50 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_v1_contact_event.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_v1_contact_event.lua @@ -1,16 +1,6 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -65,6 +55,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -89,6 +82,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -106,7 +102,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected())) test.mock_time.advance_time(10) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_vision_motion_detector.lua b/drivers/SmartThings/zwave-sensor/src/test/test_vision_motion_detector.lua index e1b82a6b38..11d62dd11f 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_vision_motion_detector.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_vision_motion_detector.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -66,6 +56,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -85,6 +78,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -104,6 +100,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -123,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -160,7 +162,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -182,7 +185,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.TEMPERATURE}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zooz_4_in_1_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zooz_4_in_1_sensor.lua index 1bef24c5d6..63b0b2bb8a 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zooz_4_in_1_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zooz_4_in_1_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -61,6 +51,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -105,7 +101,10 @@ test.register_coroutine_test( test.wait_for_events() test.mock_time.advance_time(10) test.socket.capability:__expect_send(mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear())) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -124,6 +123,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -144,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -162,6 +167,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({value = 131, unit = "lux"})) } + }, + { + min_api_version = 14 } ) @@ -182,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 80.25, unit = "F"})) } + }, + { + min_api_version = 14 } ) @@ -201,6 +212,9 @@ test.register_message_test( direction = "send", message = mock_sensor:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 53 })) } + }, + { + min_api_version = 14 } ) @@ -224,7 +238,29 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 + } +) + +test.register_message_test( + "Sensor multilevel luminance report with value=0 uses default lux conversion", + { + { + channel = "zwave", + direction = "receive", + message = { mock_sensor.id, zw_test_utils.zwave_test_build_receive_command(SensorMultilevel:Report({ + sensor_type = SensorMultilevel.sensor_type.LUMINANCE, + sensor_value = 0 })) } + }, + { + channel = "capability", + direction = "send", + message = mock_sensor:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({value = 0, unit = "lux"})) + } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_light_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_light_sensor.lua index 4ff11090c9..5559a53c79 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_light_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_light_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -67,6 +57,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -91,6 +84,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -110,6 +106,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -128,6 +127,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -147,6 +149,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -166,6 +171,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 14 } ) @@ -186,6 +194,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -205,6 +216,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -229,6 +243,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -274,7 +291,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -287,7 +305,10 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, SensorBinary:Get({sensor_type = SensorBinary.sensor_type.MOTION}))) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.LUMINANCE, scale = SensorMultilevel.scale.luminance.LUX}))) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_temp_light_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_temp_light_sensor.lua index d918f47179..b6c70bdadb 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_temp_light_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_motion_temp_light_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -63,6 +53,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -87,6 +80,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -106,6 +102,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -125,6 +124,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -144,6 +146,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 14 } ) @@ -163,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -182,6 +190,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -206,6 +217,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -231,7 +245,10 @@ test.register_coroutine_test( SensorMultilevel:Get({sensor_type = SensorMultilevel.sensor_type.LUMINANCE, scale = SensorMultilevel.scale.luminance.LUX}) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_sensor.lua index a390c35db9..29354370e4 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -139,6 +129,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -241,7 +234,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -276,6 +270,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -295,6 +292,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -314,6 +314,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -333,6 +336,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -351,6 +357,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -369,6 +378,9 @@ test.register_message_test( direction = "send", message = mock_motion_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -397,6 +409,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -421,6 +436,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -440,6 +458,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -459,6 +480,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 22 })) } + }, + { + min_api_version = 14 } ) @@ -478,6 +502,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 14 } ) @@ -497,6 +524,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -516,6 +546,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -535,6 +568,9 @@ test.register_message_test( direction = "send", message = mock_contact_device:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -564,6 +600,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -583,6 +622,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -602,6 +644,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -626,6 +671,9 @@ test.register_message_test( { device_uuid = mock_motion_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -650,6 +698,9 @@ test.register_message_test( { device_uuid = mock_water_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -674,6 +725,9 @@ test.register_message_test( { device_uuid = mock_contact_device.id, capability_id = "switch", capability_attr_id = "switch" } } } + }, + { + min_api_version = 14 } ) @@ -693,6 +747,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.moldHealthConcern.moldHealthConcern.unhealthy()) } + }, + { + min_api_version = 14 } ) @@ -712,6 +769,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.moldHealthConcern.moldHealthConcern.good()) } + }, + { + min_api_version = 14 } ) @@ -732,6 +792,51 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.dewPoint.dewpoint({value = 8, unit = "C"})) } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Basic Set value=0 for contact sensor should emit contact.closed", + { + { + channel = "zwave", + direction = "receive", + message = { mock_contact_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Set({ + value = 0 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_contact_device:generate_test_message("main", capabilities.contactSensor.contact.closed()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Basic Set value=0 for motion sensor should emit motion.inactive", + { + { + channel = "zwave", + direction = "receive", + message = { mock_motion_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Set({ + value = 0 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_motion_device:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_water_sensor.lua b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_water_sensor.lua index e6caf1f07c..fa5f64bff0 100644 --- a/drivers/SmartThings/zwave-sensor/src/test/test_zwave_water_sensor.lua +++ b/drivers/SmartThings/zwave-sensor/src/test/test_zwave_water_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -65,7 +55,10 @@ test.register_coroutine_test( WakeUp:IntervalSet({ seconds = 14400, node_id = 0}) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -97,6 +93,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -116,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -135,6 +137,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -154,6 +159,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -173,6 +181,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -189,6 +200,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -208,6 +222,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -227,6 +244,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -246,6 +266,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -265,6 +288,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/can_handle.lua new file mode 100644 index 0000000000..c05cbdcf7d --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/can_handle.lua @@ -0,0 +1,23 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_tamper_event(opts, driver, device, cmd, ...) + local cc = require "st.zwave.CommandClass" + local Notification = (require "st.zwave.CommandClass.Notification")({ version = 4 }) + local FIBARO_DOOR_WINDOW_MFR_ID = 0x010F + + if device.zwave_manufacturer_id ~= FIBARO_DOOR_WINDOW_MFR_ID and + opts.dispatcher_class == "ZwaveDispatcher" and + cmd ~= nil and + cmd.cmd_class ~= nil and + cmd.cmd_class == cc.NOTIFICATION and + cmd.cmd_id == Notification.REPORT and + cmd.args.notification_type == Notification.notification_type.HOME_SECURITY and + (cmd.args.event == Notification.event.home_security.TAMPERING_PRODUCT_COVER_REMOVED or + cmd.args.event == Notification.event.home_security.TAMPERING_PRODUCT_MOVED) then + return true, require("timed-tamper-clear") + end + return false +end + +return can_handle_tamper_event diff --git a/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua index 2007bedb0d..dd6cc672f4 100644 --- a/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/timed-tamper-clear/init.lua @@ -1,16 +1,7 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -20,23 +11,6 @@ local capabilities = require "st.capabilities" local TAMPER_TIMER = "_tamper_timer" local TAMPER_CLEAR = 10 -local FIBARO_DOOR_WINDOW_MFR_ID = 0x010F - -local function can_handle_tamper_event(opts, driver, device, cmd, ...) - if device.zwave_manufacturer_id ~= FIBARO_DOOR_WINDOW_MFR_ID and - opts.dispatcher_class == "ZwaveDispatcher" and - cmd ~= nil and - cmd.cmd_class ~= nil and - cmd.cmd_class == cc.NOTIFICATION and - cmd.cmd_id == Notification.REPORT and - cmd.args.notification_type == Notification.notification_type.HOME_SECURITY and - (cmd.args.event == Notification.event.home_security.TAMPERING_PRODUCT_COVER_REMOVED or - cmd.args.event == Notification.event.home_security.TAMPERING_PRODUCT_MOVED) then - local subdriver = require("timed-tamper-clear") - return true, subdriver - else return false - end -end -- This behavior is from zwave-door-window-sensor.groovy. We've seen this behavior -- in Ecolink and several other z-wave sensors that do not send tamper clear events @@ -48,6 +22,13 @@ local function handle_tamper_event(driver, device, cmd) device.thread:cancel_timer(tamper_timer) end device:set_field(TAMPER_TIMER, device.thread:call_with_delay(TAMPER_CLEAR, function() + -- The device can be removed while this timer is still pending. Once a device is + -- deleted its metatable is swapped so that all field/method lookups return nil + -- (see Device:deleted() in st.device), so guard against that here to avoid + -- "attempt to call a nil value (method 'emit_event_for_endpoint')" errors. + if device.id == nil then + return + end device:emit_event_for_endpoint(cmd.src_channel, capabilities.tamperAlert.tamper.clear()) device:set_field(TAMPER_TIMER, nil) end)) @@ -60,7 +41,8 @@ local timed_tamper_clear = { } }, NAME = "timed tamper clear", - can_handle = can_handle_tamper_event + can_handle = require("timed-tamper-clear.can_handle"), + shared_device_thread_enabled = true, } return timed_tamper_clear diff --git a/drivers/SmartThings/zwave-sensor/src/v1-contact-event/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/v1-contact-event/can_handle.lua new file mode 100644 index 0000000000..492a72dc74 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/v1-contact-event/can_handle.lua @@ -0,0 +1,22 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_v1_contact_event(opts, driver, device, cmd, ...) + local cc = require "st.zwave.CommandClass" + local Notification = (require "st.zwave.CommandClass.Notification")({ version = 4 }) + + if opts.dispatcher_class == "ZwaveDispatcher" and + cmd ~= nil and + cmd.cmd_class ~= nil and + cmd.cmd_class == cc.NOTIFICATION and + cmd.cmd_id == Notification.REPORT and + cmd.args.notification_type == Notification.notification_type.HOME_SECURITY and + cmd.args.v1_alarm_type == 0x07 then + local subdriver = require("v1-contact-event") + return true, subdriver + else + return false + end +end + +return can_handle_v1_contact_event diff --git a/drivers/SmartThings/zwave-sensor/src/v1-contact-event/init.lua b/drivers/SmartThings/zwave-sensor/src/v1-contact-event/init.lua index 40efc7633e..78b3beefcb 100644 --- a/drivers/SmartThings/zwave-sensor/src/v1-contact-event/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/v1-contact-event/init.lua @@ -1,16 +1,5 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -18,20 +7,6 @@ local cc = require "st.zwave.CommandClass" local Notification = (require "st.zwave.CommandClass.Notification")({ version = 4 }) local capabilities = require "st.capabilities" -local function can_handle_v1_contact_event(opts, driver, device, cmd, ...) - if opts.dispatcher_class == "ZwaveDispatcher" and - cmd ~= nil and - cmd.cmd_class ~= nil and - cmd.cmd_class == cc.NOTIFICATION and - cmd.cmd_id == Notification.REPORT and - cmd.args.notification_type == Notification.notification_type.HOME_SECURITY and - cmd.args.v1_alarm_type == 0x07 then - local subdriver = require("v1-contact-event") - return true, subdriver - else - return false - end -end -- This behavior is from zwave-door-window-sensor.groovy, where it is -- indicated that certain monoprice sensors had this behavior. Also, @@ -53,7 +28,8 @@ local v1_contact_event = { } }, NAME = "v1 contact event", - can_handle = can_handle_v1_contact_event + can_handle = require("v1-contact-event.can_handle"), + shared_device_thread_enabled = true, } return v1_contact_event diff --git a/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/can_handle.lua new file mode 100644 index 0000000000..d270a7954d --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/can_handle.lua @@ -0,0 +1,17 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is zwave-plus-motion-temp-sensor +local function can_handle_vision_motion_detector(opts, driver, device, ...) + local VISION_MOTION_DETECTOR_FINGERPRINTS = { manufacturerId = 0x0109, productType = 0x2002, productId = 0x0205 } -- Vision Motion Detector ZP3102 + if device:id_match( + VISION_MOTION_DETECTOR_FINGERPRINTS.manufacturerId, + VISION_MOTION_DETECTOR_FINGERPRINTS.productType, + VISION_MOTION_DETECTOR_FINGERPRINTS.productId + ) then + return true, require("vision-motion-detector") + end + return false +end + +return can_handle_vision_motion_detector diff --git a/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/init.lua b/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/init.lua index 320bf3824f..f9e8f8a04e 100644 --- a/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/vision-motion-detector/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -22,20 +13,6 @@ local Configuration = (require "st.zwave.CommandClass.Configuration")({ version --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) -local VISION_MOTION_DETECTOR_FINGERPRINTS = { manufacturerId = 0x0109, productType = 0x2002, productId = 0x0205 } -- Vision Motion Detector ZP3102 - ---- Determine whether the passed device is zwave-plus-motion-temp-sensor -local function can_handle_vision_motion_detector(opts, driver, device, ...) - if device:id_match( - VISION_MOTION_DETECTOR_FINGERPRINTS.manufacturerId, - VISION_MOTION_DETECTOR_FINGERPRINTS.productType, - VISION_MOTION_DETECTOR_FINGERPRINTS.productId - ) then - local subdriver = require("vision-motion-detector") - return true, subdriver - else return false end -end - --- Handler for notification report command class from sensor --- --- @param self st.zwave.Driver @@ -83,7 +60,8 @@ local vision_motion_detector = { doConfigure = do_configure, }, NAME = "Vision motion detector", - can_handle = can_handle_vision_motion_detector + can_handle = require("vision-motion-detector.can_handle"), + shared_device_thread_enabled = true, } return vision_motion_detector diff --git a/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/can_handle.lua new file mode 100644 index 0000000000..15ac66d439 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device, ...) + local fingerprint = {manufacturerId = 0x014F, productType = 0x2001, productId = 0x0102} -- NorTek open/close sensor + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("wakeup-no-poll") + end + return false +end + +return can_handle diff --git a/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/init.lua b/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/init.lua index 59d298a0e4..42163aeaae 100644 --- a/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/wakeup-no-poll/init.lua @@ -1,16 +1,7 @@ --- Copyright 2023 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. +-- Copyright 2023 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass local cc = require "st.zwave.CommandClass" @@ -21,17 +12,6 @@ local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({version = 2 --- @type st.zwave.CommandClass.Battery local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) -local fingerprint = {manufacturerId = 0x014F, productType = 0x2001, productId = 0x0102} -- NorTek open/close sensor - -local function can_handle(opts, driver, device, ...) - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - local subdriver = require("wakeup-no-poll") - return true, subdriver - else - return false - end -end - -- Nortek open/closed sensors _always_ respond with "open" when polled, and they are polled after wakeup local function wakeup_notification(driver, device, cmd) --Note sending WakeUpIntervalGet the first time a device wakes up will happen by default in Lua libs 0.49.x and higher @@ -53,7 +33,8 @@ local wakeup_no_poll = { [WakeUp.NOTIFICATION] = wakeup_notification } }, - can_handle = can_handle + can_handle = require("wakeup-no-poll.can_handle"), + shared_device_thread_enabled = true, } -return wakeup_no_poll \ No newline at end of file +return wakeup_no_poll diff --git a/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/can_handle.lua new file mode 100644 index 0000000000..e979e32153 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zooz_4_in_1_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("zooz-4-in-1-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + local subdriver = require("zooz-4-in-1-sensor") + return true, subdriver + end + end + return false +end + +return can_handle_zooz_4_in_1_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/fingerprints.lua new file mode 100644 index 0000000000..12d853b147 --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZOOZ_4_IN_1_FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x2021, productId = 0x2101 }, -- Zooz 4-in-1 sensor + { manufacturerId = 0x0109, productType = 0x2021, productId = 0x2101 }, -- ZP3111US 4-in-1 Motion + { manufacturerId = 0x0060, productType = 0x0001, productId = 0x0004 } -- Everspring Immune Pet PIR Sensor SP815 +} + +return ZOOZ_4_IN_1_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/init.lua index 5d4570e525..2a4053302b 100644 --- a/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/zooz-4-in-1-sensor/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -22,22 +13,8 @@ local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ ve --- @type st.utils local utils = require "st.utils" -local ZOOZ_4_IN_1_FINGERPRINTS = { - { manufacturerId = 0x027A, productType = 0x2021, productId = 0x2101 }, -- Zooz 4-in-1 sensor - { manufacturerId = 0x0109, productType = 0x2021, productId = 0x2101 }, -- ZP3111US 4-in-1 Motion - { manufacturerId = 0x0060, productType = 0x0001, productId = 0x0004 } -- Everspring Immune Pet PIR Sensor SP815 -} --- Determine whether the passed device is zooz_4_in_1_sensor -local function can_handle_zooz_4_in_1_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(ZOOZ_4_IN_1_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - local subdriver = require("zooz-4-in-1-sensor") - return true, subdriver - end - end - return false -end --- Handler for notification report command class --- @@ -109,7 +86,8 @@ local zooz_4_in_1_sensor = { } }, NAME = "zooz 4 in 1 sensor", - can_handle = can_handle_zooz_4_in_1_sensor + can_handle = require("zooz-4-in-1-sensor.can_handle"), + shared_device_thread_enabled = true, } return zooz_4_in_1_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/can_handle.lua b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/can_handle.lua new file mode 100644 index 0000000000..63da2ae39c --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/can_handle.lua @@ -0,0 +1,15 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_water_leak_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-water-leak-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + local subdriver = require("zwave-water-leak-sensor") + return true, subdriver + end + end + return false +end + +return can_handle_water_leak_sensor diff --git a/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/fingerprints.lua b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/fingerprints.lua new file mode 100644 index 0000000000..c07bdb52cb --- /dev/null +++ b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/fingerprints.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local WATER_LEAK_SENSOR_FINGERPRINTS = { + {mfr = 0x0084, prod = 0x0063, model = 0x010C}, -- SmartThings Water Leak Sensor + {mfr = 0x0084, prod = 0x0053, model = 0x0216}, -- FortrezZ Water Leak Sensor + {mfr = 0x021F, prod = 0x0003, model = 0x0085}, -- Dome Leak Sensor + {mfr = 0x0258, prod = 0x0003, model = 0x0085}, -- NEO Coolcam Water Sensor + {mfr = 0x0258, prod = 0x0003, model = 0x1085}, -- NEO Coolcam Water Sensor + {mfr = 0x0258, prod = 0x0003, model = 0x2085}, -- NEO Coolcam Water Sensor + {mfr = 0x0086, prod = 0x0002, model = 0x007A}, -- Aeotec Water Sensor 6 (EU) + {mfr = 0x0086, prod = 0x0102, model = 0x007A}, -- Aeotec Water Sensor 6 (US) + {mfr = 0x0086, prod = 0x0202, model = 0x007A}, -- Aeotec Water Sensor 6 (AU) + {mfr = 0x000C, prod = 0x0201, model = 0x000A}, -- HomeSeer LS100+ Water Sensor + {mfr = 0x0173, prod = 0x4C47, model = 0x4C44}, -- Leak Gopher Z-Wave Leak Detector + {mfr = 0x027A, prod = 0x7000, model = 0xE002} -- Zooz ZSE42 XS Water Leak Sensor +} + +return WATER_LEAK_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/init.lua b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/init.lua index 1eefab7479..6a956a4343 100644 --- a/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/init.lua +++ b/drivers/SmartThings/zwave-sensor/src/zwave-water-leak-sensor/init.lua @@ -1,47 +1,10 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) - -local WATER_LEAK_SENSOR_FINGERPRINTS = { - {mfr = 0x0084, prod = 0x0063, model = 0x010C}, -- SmartThings Water Leak Sensor - {mfr = 0x0084, prod = 0x0053, model = 0x0216}, -- FortrezZ Water Leak Sensor - {mfr = 0x021F, prod = 0x0003, model = 0x0085}, -- Dome Leak Sensor - {mfr = 0x0258, prod = 0x0003, model = 0x0085}, -- NEO Coolcam Water Sensor - {mfr = 0x0258, prod = 0x0003, model = 0x1085}, -- NEO Coolcam Water Sensor - {mfr = 0x0258, prod = 0x0003, model = 0x2085}, -- NEO Coolcam Water Sensor - {mfr = 0x0086, prod = 0x0002, model = 0x007A}, -- Aeotec Water Sensor 6 (EU) - {mfr = 0x0086, prod = 0x0102, model = 0x007A}, -- Aeotec Water Sensor 6 (US) - {mfr = 0x0086, prod = 0x0202, model = 0x007A}, -- Aeotec Water Sensor 6 (AU) - {mfr = 0x000C, prod = 0x0201, model = 0x000A}, -- HomeSeer LS100+ Water Sensor - {mfr = 0x0173, prod = 0x4C47, model = 0x4C44}, -- Leak Gopher Z-Wave Leak Detector - {mfr = 0x027A, prod = 0x7000, model = 0xE002} -- Zooz ZSE42 XS Water Leak Sensor -} - -local function can_handle_water_leak_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(WATER_LEAK_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - local subdriver = require("zwave-water-leak-sensor") - return true, subdriver - end - end - return false -end - local function basic_set_handler(driver, device, cmd) local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value device:emit_event(value == 0xFF and capabilities.waterSensor.water.wet() or capabilities.waterSensor.water.dry()) @@ -54,7 +17,8 @@ local water_leak_sensor = { [Basic.SET] = basic_set_handler } }, - can_handle = can_handle_water_leak_sensor + can_handle = require("zwave-water-leak-sensor.can_handle"), + shared_device_thread_enabled = true, } return water_leak_sensor diff --git a/drivers/SmartThings/zwave-siren/fingerprints.yml b/drivers/SmartThings/zwave-siren/fingerprints.yml index 1e36e4af81..4b25211366 100644 --- a/drivers/SmartThings/zwave-siren/fingerprints.yml +++ b/drivers/SmartThings/zwave-siren/fingerprints.yml @@ -1,10 +1,16 @@ zwaveManufacturer: - - id: "Zooz" + - id: "Zooz/ZSE19" deviceLabel: Zooz Multisiren manufacturerId: 0x027A productType: 0x000C productId: 0x0003 deviceProfileName: multifunctional-siren + - id: "Zooz/ZSE50" + deviceLabel: Zooz ZSE50 Siren and Chime + manufacturerId: 0x027A + productType: 0x0004 + productId: 0x0369 + deviceProfileName: zooz-zse50 - id: "Everspring" deviceLabel: Everspring Siren manufacturerId: 0x0060 diff --git a/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml b/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml new file mode 100644 index 0000000000..866b88f436 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml @@ -0,0 +1,309 @@ +# Zooz ZSE50 Siren/Chime +# With deviceConfig - allows setting a tone from routines +name: zooz-zse50 +components: + - id: main + capabilities: + - id: alarm + version: 1 + - id: chime + version: 1 + - id: mode + version: 1 + - id: powerSource + version: 1 + - id: audioVolume + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren +### PREFERENCES ### +preferences: + #param 1 + - name: "playbackMode" + title: "Playback Mode" + description: "* = Default; Set siren playback mode: once (0), loop for x seconds (1), loop x times (2), loop until cancel (3), no sound (4)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Play once *" + 1: "Play in loop for set duration" + 2: "Play in loop for set number" + 3: "Play in loop until stopped" + 4: "No sound, LED only" + default: 0 + #param 2 + - name: "playbackDuration" + title: "Playback Duration" + description: "Default: 180; Set playback duration for the siren (in seconds) when the siren is in playback mode 1." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 900 + default: 180 + #param 3 + - name: "playbackLoop" + title: "Playback Loop Count" + description: "Default: 1; Set the number of playback loops for the selected tone when the siren is in playback mode 2." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 99 + default: 1 + #param 4 + - name: "playbackTone" + title: "Playback Tone" + description: "Set the default tone for the siren playback. Choose the number of the file in the library as value. Check the 'modes' list for the id numbers" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 50 + default: 1 + #param 5 - playbackVolume ## Handled with volume command + #param 6 + - name: "ledMode" + title: "LED Indicator Mode" + description: "* = Default; Set the LED indicator mode for the siren: off (0), strobe (1), police strobe (2), pulse (3), solid on (4). See documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "LED always off" + 1: "LED strobe single color *" + 2: "LED strobe red and blue" + 3: "LED pulse single color" + 4: "LED solid on single color" + default: 1 + #param 7 + - name: "ledColor" + title: "LED Indicator Color" + description: "Default: 0; Set the LED indicator color: red (0), yellow (42), green (85), indigo (127), blue (170), purple (212), or white (255). More colors available through custom values corresponding to the color wheel. See advanced documentation for details." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 255 + default: 0 + #param 8 + - name: "lowBattery" + title: "Low Battery Report" + description: "Which % level should the device report low battery to the hub." + required: false + preferenceType: enumeration + definition: + options: + 10: "10% [DEFAULT]" + 15: "15%" + 20: "20%" + 25: "25%" + 30: "30%" + 35: "35%" + 40: "40%" + default: 10 + #param 9 + - name: "ledBatteryMode" + title: "LED In Back-Up Battery Mode" + description: "* = Default; Set the LED indicator in back-up battery mode: off (0), regular LED mode (1), pulse white for full battery and red for low battery (2)." + required: false + preferenceType: enumeration + definition: + options: + 0: "LED off" + 1: "Regular LED mode *" + 2: "Pulse white for full, red for low" + default: 1 + #param 10 + - name: "btnToneSelection" + title: "Button Tone Selection" + description: "Disable tone selection from physical buttons on the siren (0). When disabled, you'll only be able to program tones using the advanced parameters in the Z-Wave UI. Expert users only, see documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Enabled [DEFAULT]" + default: 1 + #param 11 + - name: "btnVolSelection" + title: "Button Volume Selection" + description: "Disable volume adjustment from physical buttons on the siren (0). When disabled, you'll only be able to adjust volume using the advanced parameters in the Z-Wave UI. Expert users only, see documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Enabled [DEFAULT]" + default: 1 + #param 13 + - name: "systemVolume" + title: "System Message Volume" + description: "Default: 50; Set system message volume (0-100, 0 – mute)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 50 + #param 14 + - name: "ledBrightness" + title: "LED Indicator Brightness" + description: "Default: 5; Choose the LED indicator's brightness level (0 – off, 10 – high brightness)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 10 + default: 5 + #param 15 + - name: "batteryFrequency" + title: "Battery Reporting Frequency" + description: "Default: 12; Set the reporting interval for battery (1-84 hours)." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 84 + default: 12 + #param 16 + - name: "batteryThreshold" + title: "Battery Reporting Threshold" + description: "Default: 0; Set the threshold for battery reporting in % changes. Set to 0 to disable reporting based on threshold." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 20 + default: 0 + +### DEVICE CONFIG ### +deviceConfig: + dashboard: + states: + - component: main + capability: chime + version: 1 + actions: + - component: main + capability: chime + version: 1 + basicPlus: [ ] + detailView: + - component: main + capability: alarm + version: 1 + values: + - key: alarm.value + enabledValues: + - 'off' + - 'both' + - key: "{{enumCommands}}" + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + - component: main + capability: powerSource + version: 1 + values: + - key: powerSource.value + enabledValues: + - 'battery' + - 'mains' + - component: main + capability: audioVolume + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: alarm + version: 1 + values: + - key: alarm.value + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + command: setMode + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + - component: main + capability: powerSource + version: 1 + values: + - key: powerSource.value + enabledValues: + - 'battery' + - 'mains' + step: 1 + - component: main + capability: audioVolume + version: 1 + - component: main + capability: battery + version: 1 + actions: + - component: main + capability: alarm + version: 1 + values: + - key: "{{enumCommands}}" + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + command: setMode + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + - component: main + capability: audioVolume + version: 1 diff --git a/drivers/SmartThings/zwave-siren/src/aeon-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/aeon-siren/can_handle.lua new file mode 100644 index 0000000000..2aae744ccc --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/aeon-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeon_siren(opts, driver, device, ...) + local AEON_MFR = 0x0086 + local AEON_SIREN_PRODUCT_ID = 0x0050 + + if device.zwave_manufacturer_id == AEON_MFR and device.zwave_product_id == AEON_SIREN_PRODUCT_ID then + return true, require("aeon-siren") + end + return false +end + +return can_handle_aeon_siren diff --git a/drivers/SmartThings/zwave-siren/src/aeon-siren/init.lua b/drivers/SmartThings/zwave-siren/src/aeon-siren/init.lua index f98ba06d83..1c1a2bb0b1 100644 --- a/drivers/SmartThings/zwave-siren/src/aeon-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/aeon-siren/init.lua @@ -1,32 +1,16 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=1 }) -local AEON_MFR = 0x0086 -local AEON_SIREN_PRODUCT_ID = 0x0050 - local SOUND_TYPE_AND_VOLUME_PARAMETER_NUMBER = 37 local CONFIGURE_SOUND_TYPE = "type" local SOUND_TYPE_DEFAULT = 1 local CONFIGURE_VOLUME = "volume" local VOLUME_DEFAULT = 3 -local function can_handle_aeon_siren(opts, driver, device, ...) - return device.zwave_manufacturer_id == AEON_MFR and device.zwave_product_id == AEON_SIREN_PRODUCT_ID -end local function configure_sound(device, sound_type, volume) if sound_type == nil then sound_type = SOUND_TYPE_DEFAULT end @@ -64,7 +48,7 @@ end local aeon_siren = { NAME = "aeon-siren", - can_handle = can_handle_aeon_siren, + can_handle = require("aeon-siren.can_handle"), lifecycle_handlers = { doConfigure = do_configure, infoChanged = info_changed diff --git a/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/can_handle.lua new file mode 100644 index 0000000000..31304e8688 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_doorbell_siren(opts, driver, device, ...) + local FINGERPRINTS = require("aeotec-doorbell-siren.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("aeotec-doorbell-siren") + end + end + return false +end + +return can_handle_aeotec_doorbell_siren diff --git a/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/fingerprints.lua new file mode 100644 index 0000000000..782dfbc0c9 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/fingerprints.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_DOORBELL_SIREN_FINGERPRINTS = { + { manufacturerId = 0x0371, productType = 0x0003, productId = 0x00A2}, -- Aeotec Doorbell 6 (EU) + { manufacturerId = 0x0371, productType = 0x0103, productId = 0x00A2}, -- Aeotec Doorbell 6 (US) + { manufacturerId = 0x0371, productType = 0x0203, productId = 0x00A2}, -- Aeotec Doorbell 6 (AU) + { manufacturerId = 0x0371, productType = 0x0003, productId = 0x00A4}, -- Aeotec Siren 6 (EU) + { manufacturerId = 0x0371, productType = 0x0103, productId = 0x00A4}, -- Aeotec Siren 6 (US) + { manufacturerId = 0x0371, productType = 0x0203, productId = 0x00A4}, -- Aeotec Siren 6 (AU) +} + +return AEOTEC_DOORBELL_SIREN_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/init.lua b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/init.lua index bab6909235..716c46186e 100644 --- a/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/aeotec-doorbell-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -20,14 +10,6 @@ local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) local SoundSwitch = (require "st.zwave.CommandClass.SoundSwitch")({version=1}) local preferencesMap = require "preferences" -local AEOTEC_DOORBELL_SIREN_FINGERPRINTS = { - { manufacturerId = 0x0371, productType = 0x0003, productId = 0x00A2}, -- Aeotec Doorbell 6 (EU) - { manufacturerId = 0x0371, productType = 0x0103, productId = 0x00A2}, -- Aeotec Doorbell 6 (US) - { manufacturerId = 0x0371, productType = 0x0203, productId = 0x00A2}, -- Aeotec Doorbell 6 (AU) - { manufacturerId = 0x0371, productType = 0x0003, productId = 0x00A4}, -- Aeotec Siren 6 (EU) - { manufacturerId = 0x0371, productType = 0x0103, productId = 0x00A4}, -- Aeotec Siren 6 (US) - { manufacturerId = 0x0371, productType = 0x0203, productId = 0x00A4}, -- Aeotec Siren 6 (AU) -} local COMPONENT_NAME = "componentName" local TONE = "tone" @@ -51,15 +33,6 @@ local BUTTON_BATTERY_NORMAL = 99 local DEVICE_PROFILE_CHANGE_IN_PROGRESS = "device_profile_change_in_progress" local NEXT_BUTTON_BATTERY_EVENT_DETAILS = "next_button_battery_event_details" -local function can_handle_aeotec_doorbell_siren(opts, driver, device, ...) - for _, fingerprint in ipairs(AEOTEC_DOORBELL_SIREN_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end - local function querySoundStatus(device) for endpoint = 2, NUMBER_OF_SOUND_COMPONENTS do device:send_to_component(Basic:Get({}), "sound"..endpoint) @@ -234,6 +207,7 @@ local function changeDeviceProfileIfNeeded(device, endpoint) end end +-- Note that endpoint should be a number not a dst_channels table. local function setActiveEndpoint(device, endpoint) if (endpoint) then device:set_field(LAST_TRIGGERED_ENDPOINT, endpoint, {persist = true}) @@ -297,26 +271,28 @@ end local function alarmChimeOnOff(device, command, newValue) if (device and command and newValue) then + -- Note that zwave/device.lua send_to_component expects the component_to_endpoint function to + -- return a dst_channels table, not a single endpoint number local endpoint = component_to_endpoint(device, command.component) - device:send(Basic:Set({value = newValue})):to_endpoint(endpoint) + device:send_to_component(Basic:Set({value = newValue}), command.component) if (newValue == ON) then - setActiveEndpoint(endpoint) + setActiveEndpoint(device, endpoint[1]) end end end -local function alarm_chime_on(device, command) +local function alarm_chime_on(self, device, command) resetActiveEndpoint(device) alarmChimeOnOff(device, command, ON) end -local function alarm_chime_off(device, command) +local function alarm_chime_off(self, device, command) alarmChimeOnOff(device, command, OFF) end local aeotec_doorbell_siren = { NAME = "aeotec-doorbell-siren", - can_handle = can_handle_aeotec_doorbell_siren, + can_handle = require("aeotec-doorbell-siren.can_handle"), lifecycle_handlers = { added = device_added, @@ -332,6 +308,9 @@ local aeotec_doorbell_siren = { [Notification.REPORT] = notification_report_handler } }, + -- This typo is a bug. There are many unit tests that fail, when + -- it is enabled, and it is not clear what the correct functionality is + -- without real device testing. capabilities_handlers = { [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh diff --git a/drivers/SmartThings/zwave-siren/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-siren/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-siren/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-siren/src/configurations.lua b/drivers/SmartThings/zwave-siren/src/configurations.lua index b17b3cbc0f..5165ee4551 100644 --- a/drivers/SmartThings/zwave-siren/src/configurations.lua +++ b/drivers/SmartThings/zwave-siren/src/configurations.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { YALE_SIREN = { diff --git a/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/can_handle.lua new file mode 100644 index 0000000000..5803087913 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_ecolink_wireless_siren(opts, driver, device, ...) + local FINGERPRINTS = require("ecolink-wireless-siren.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("ecolink-wireless-siren") + end + end + return false +end + +return can_handle_ecolink_wireless_siren diff --git a/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/fingerprints.lua new file mode 100644 index 0000000000..9edaef0374 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ECOLINK_WIRELESS_SIREN_FINGERPRINTS = { + { manufacturerId = 0x014A, productType = 0x0005, productId = 0x000A }, -- Ecolink Siren +} + +return ECOLINK_WIRELESS_SIREN_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/init.lua b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/init.lua index 0413221185..6b5a2a25ad 100644 --- a/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/ecolink-wireless-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,18 +10,7 @@ local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) --- @type st.zwave.CommandClass.SwitchBinary local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({ version = 2 }) -local ECOLINK_WIRELESS_SIREN_FINGERPRINTS = { - { manufacturerId = 0x014A, productType = 0x0005, productId = 0x000A }, -- Ecolink Siren -} -local function can_handle_ecolink_wireless_siren(opts, driver, device, ...) - for _, fingerprint in ipairs(ECOLINK_WIRELESS_SIREN_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function basic_set_handler(driver, device, cmd) local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value @@ -103,7 +82,7 @@ local ecolink_wireless_siren = { lifecycle_handlers = { init = device_init }, - can_handle = can_handle_ecolink_wireless_siren, + can_handle = require("ecolink-wireless-siren.can_handle"), } return ecolink_wireless_siren diff --git a/drivers/SmartThings/zwave-siren/src/fortrezz/can_handle.lua b/drivers/SmartThings/zwave-siren/src/fortrezz/can_handle.lua new file mode 100644 index 0000000000..83289ab86a --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/fortrezz/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fortrezz_siren(opts, self, device, ...) + if device.zwave_manufacturer_id == 0x0084 and + device.zwave_product_type == 0x0313 and + device.zwave_product_id == 0x010B then + return true, require("fortrezz") + end + return false +end + +return can_handle_fortrezz_siren diff --git a/drivers/SmartThings/zwave-siren/src/fortrezz/init.lua b/drivers/SmartThings/zwave-siren/src/fortrezz/init.lua index 73a3e6e0d4..8bf31178f6 100644 --- a/drivers/SmartThings/zwave-siren/src/fortrezz/init.lua +++ b/drivers/SmartThings/zwave-siren/src/fortrezz/init.lua @@ -1,26 +1,11 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local capabilities = require "st.capabilities" local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) -local function can_handle_fortrezz_siren(opts, self, device, ...) - return device.zwave_manufacturer_id == 0x0084 and - device.zwave_product_type == 0x0313 and - device.zwave_product_id == 0x010B -end local function set_and_get(value) return function (self, device, command) @@ -47,7 +32,7 @@ end local fortrezz_siren = { NAME = "fortrezz-siren", - can_handle = can_handle_fortrezz_siren, + can_handle = require("fortrezz.can_handle"), capability_handlers = { [capabilities.alarm.ID] = { [capabilities.alarm.commands.siren.NAME] = set_and_get(0x42), @@ -67,4 +52,4 @@ local fortrezz_siren = { } } -return fortrezz_siren \ No newline at end of file +return fortrezz_siren diff --git a/drivers/SmartThings/zwave-siren/src/init.lua b/drivers/SmartThings/zwave-siren/src/init.lua index a725e8b79c..94e8b723b9 100644 --- a/drivers/SmartThings/zwave-siren/src/init.lua +++ b/drivers/SmartThings/zwave-siren/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cap_defaults = require "st.capabilities.defaults" @@ -90,26 +80,16 @@ local driver_template = { capabilities.tamperAlert, capabilities.temperatureMeasurement, capabilities.relativeHumidityMeasurement, - capabilities.chime - }, - sub_drivers = { - require("multifunctional-siren"), - require("zwave-sound-sensor"), - require("ecolink-wireless-siren"), - require("philio-sound-siren"), - require("aeotec-doorbell-siren"), - require("aeon-siren"), - require("yale-siren"), - require("zipato-siren"), - require("utilitech-siren"), - require("fortrezz"), - require("apiv6_bugfix"), + capabilities.chime, + capabilities.powerSource }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { infoChanged = info_changed, doConfigure = do_configure, added = added_handler - } + }, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-siren/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-siren/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-siren/src/multifunctional-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/can_handle.lua new file mode 100644 index 0000000000..a3c62d02aa --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multifunctional_siren(opts, driver, device, ...) + local FINGERPRINTS = require("multifunctional-siren.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("multifunctional-siren") + end + end + return false +end + +return can_handle_multifunctional_siren diff --git a/drivers/SmartThings/zwave-siren/src/multifunctional-siren/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/fingerprints.lua new file mode 100644 index 0000000000..d2bcf5402d --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local MULTIFUNCTIONAL_SIREN_FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x000C, productId = 0x0003 }, -- Zooz S2 Multisiren ZSE19 + { manufacturerId = 0x0060, productType = 0x000C, productId = 0x0003 } -- Everspring Indoor Voice Siren +} + +return MULTIFUNCTIONAL_SIREN_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/multifunctional-siren/init.lua b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/init.lua index 2c8af3bb26..b1bca996d5 100644 --- a/drivers/SmartThings/zwave-siren/src/multifunctional-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/multifunctional-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,24 +10,12 @@ local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) --- @type st.zwave.CommandClass.Battery local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local MULTIFUNCTIONAL_SIREN_FINGERPRINTS = { - { manufacturerId = 0x027A, productType = 0x000C, productId = 0x0003 }, -- Zooz S2 Multisiren ZSE19 - { manufacturerId = 0x0060, productType = 0x000C, productId = 0x0003 } -- Everspring Indoor Voice Siren -} --- Determine whether the passed device is multifunctional siren --- --- @param driver Driver driver instance --- @param device Device device isntance --- @return boolean true if the device proper, else false -local function can_handle_multifunctional_siren(opts, driver, device, ...) - for _, fingerprint in ipairs(MULTIFUNCTIONAL_SIREN_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end --- Default handler for notification command class reports --- @@ -74,7 +52,7 @@ local multifunctional_siren = { doConfigure = do_configure }, NAME = "multifunctional siren", - can_handle = can_handle_multifunctional_siren, + can_handle = require("multifunctional-siren.can_handle"), } return multifunctional_siren diff --git a/drivers/SmartThings/zwave-siren/src/philio-sound-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/can_handle.lua new file mode 100644 index 0000000000..23e614a2e2 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_philio_sound_siren(opts, driver, device, ...) + local FINGERPRINTS = require("philio-sound-siren.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("philio-sound-siren") + end + end + return false +end + +return can_handle_philio_sound_siren diff --git a/drivers/SmartThings/zwave-siren/src/philio-sound-siren/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/fingerprints.lua new file mode 100644 index 0000000000..e1f34617fc --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local PHILIO_SOUND_SIREN = { + { manufacturerId = 0x013C, productType = 0x0004, productId = 0x000A } +} + +return PHILIO_SOUND_SIREN diff --git a/drivers/SmartThings/zwave-siren/src/philio-sound-siren/init.lua b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/init.lua index 6397022dd8..23e310bf14 100644 --- a/drivers/SmartThings/zwave-siren/src/philio-sound-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/philio-sound-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local capabilities = require "st.capabilities" @@ -19,9 +9,6 @@ local SensorBinary = (require "st.zwave.CommandClass.SensorBinary")({version=2}) local Notification = (require "st.zwave.CommandClass.Notification")({ version = 3 }) local preferencesMap = require "preferences" -local PHILIO_SOUND_SIREN = { - { manufacturerId = 0x013C, productType = 0x0004, productId = 0x000A } -} local PARAMETER_SOUND = "sound" local SMOKE = 0 @@ -43,14 +30,6 @@ local sounds = { [SMOKE] = {notificationType = Notification.notification_type.SMOKE, event = Notification.event.smoke.DETECTED_LOCATION_PROVIDED} } -local function can_handle_philio_sound_siren(opts, driver, device, ...) - for _, fingerprint in ipairs(PHILIO_SOUND_SIREN) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function device_added(self, device) device:refresh() @@ -157,7 +136,7 @@ end local philio_sound_siren = { NAME = "Philio sound siren", - can_handle = can_handle_philio_sound_siren, + can_handle = require("philio-sound-siren.can_handle"), lifecycle_handlers = { added = device_added }, diff --git a/drivers/SmartThings/zwave-siren/src/preferences.lua b/drivers/SmartThings/zwave-siren/src/preferences.lua index 3e9f8333ab..3cf2fe91b5 100644 --- a/drivers/SmartThings/zwave-siren/src/preferences.lua +++ b/drivers/SmartThings/zwave-siren/src/preferences.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { PHILIO_SOUND_SIREN = { @@ -56,7 +46,32 @@ local devices = { PARAMETERS = { alarmLength = {parameter_number = 1, size = 2} } - } + }, + ZOOZ_ZSE50_SIREN = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x0004, + product_ids = 0x0369 + }, + PARAMETERS = { + playbackMode = { parameter_number = 1, size = 1 }, + playbackDuration = { parameter_number = 2, size = 2 }, + playbackLoop = { parameter_number = 3, size = 1 }, + playbackTone = { parameter_number = 4, size = 1 }, + playbackVolume = { parameter_number = 5, size = 1 }, + ledMode = { parameter_number = 6, size = 1 }, + ledColor = { parameter_number = 7, size = 1 }, + lowBattery = { parameter_number = 8, size = 1 }, + ledBatteryMode = { parameter_number = 9, size = 1 }, + btnToneSelection = { parameter_number = 10, size = 1 }, + btnVolSelection = { parameter_number = 11, size = 1 }, + basicSetGrp2 = { parameter_number = 12, size = 1 }, --Not Used + systemVolume = { parameter_number = 13, size = 1 }, + ledBrightness = { parameter_number = 14, size = 1 }, + batteryFrequency = { parameter_number = 15, size = 1 }, + batteryThreshold = { parameter_number = 16, size = 1 } + } + }, } local preferences = {} @@ -80,4 +95,5 @@ preferences.to_numeric_value = function(new_value) end return numeric end + return preferences diff --git a/drivers/SmartThings/zwave-siren/src/sub_drivers.lua b/drivers/SmartThings/zwave-siren/src/sub_drivers.lua new file mode 100644 index 0000000000..52d5035a16 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/sub_drivers.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("multifunctional-siren"), + lazy_load_if_possible("zooz-zse50"), + lazy_load_if_possible("zwave-sound-sensor"), + lazy_load_if_possible("ecolink-wireless-siren"), + lazy_load_if_possible("philio-sound-siren"), + lazy_load_if_possible("aeotec-doorbell-siren"), + lazy_load_if_possible("aeon-siren"), + lazy_load_if_possible("yale-siren"), + lazy_load_if_possible("zipato-siren"), + lazy_load_if_possible("utilitech-siren"), + lazy_load_if_possible("fortrezz"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-siren/src/test/test_aeon_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_aeon_siren.lua index 714dc17a10..8e8137cc90 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_aeon_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_aeon_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -64,6 +54,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -87,6 +80,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -115,7 +111,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -143,7 +142,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) @@ -172,7 +174,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -200,7 +205,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -228,7 +236,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -252,7 +263,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -277,7 +291,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -303,7 +320,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_aeotec_doorbell_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_aeotec_doorbell_siren.lua index c2dc708cfe..d58fbfc207 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_aeotec_doorbell_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_aeotec_doorbell_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -121,7 +111,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("main", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -156,7 +149,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound2", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -191,7 +187,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound3", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -226,7 +225,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound4", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -261,7 +263,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound5", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -296,7 +301,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound6", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -331,7 +339,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound7", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -366,7 +377,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound8", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -409,7 +423,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -453,7 +468,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -497,7 +513,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -541,7 +558,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -585,7 +603,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -629,7 +648,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -673,7 +693,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -717,7 +738,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -755,7 +777,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -792,7 +817,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -829,7 +857,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -866,7 +897,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -903,7 +937,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -940,7 +977,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -977,7 +1017,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1014,7 +1057,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1051,7 +1097,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1088,7 +1137,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1125,7 +1177,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1162,7 +1217,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1199,7 +1257,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1236,7 +1297,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1273,7 +1337,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1310,7 +1377,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1347,7 +1417,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1384,7 +1457,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1421,7 +1497,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1458,7 +1537,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1495,7 +1577,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1532,7 +1617,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1569,7 +1657,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1606,7 +1697,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1643,7 +1737,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1680,7 +1777,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1717,7 +1817,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1754,7 +1857,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1791,7 +1897,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1828,7 +1937,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1865,7 +1977,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1902,7 +2017,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1939,7 +2057,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1976,7 +2097,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2013,7 +2137,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2050,7 +2177,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2087,7 +2217,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2124,7 +2257,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2161,7 +2297,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2198,7 +2337,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2235,7 +2377,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2272,7 +2417,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2309,7 +2457,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2346,7 +2497,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2383,7 +2537,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2420,7 +2577,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2457,7 +2617,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2494,7 +2657,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -2515,7 +2681,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -2537,7 +2704,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -2568,7 +2736,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2581,7 +2752,10 @@ test.register_coroutine_test( _preferences.configureSoundAndVolume = false test.socket.device_lifecycle:__queue_receive(mock_siren:generate_info_changed({ preferences = _preferences})) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2603,7 +2777,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2614,7 +2791,10 @@ test.register_coroutine_test( _preferences.triggerButtonUnpairing = false test.socket.device_lifecycle:__queue_receive(mock_siren:generate_info_changed({ preferences = _preferences})) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2636,7 +2816,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2647,7 +2830,10 @@ test.register_coroutine_test( _preferences.triggerButtonPairing = false test.socket.device_lifecycle:__queue_receive(mock_siren:generate_info_changed({ preferences = _preferences})) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -2672,6 +2858,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound3", capabilities.battery.battery(BUTTON_BATTERY_LOW)) } + }, + { + min_api_version = 14 } ) @@ -2697,6 +2886,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound3", capabilities.battery.battery(BUTTON_BATTERY_NORMAL)) } + }, + { + min_api_version = 14 } ) @@ -2722,6 +2914,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound4", capabilities.battery.battery(BUTTON_BATTERY_LOW)) } + }, + { + min_api_version = 14 } ) @@ -2747,6 +2942,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound4", capabilities.battery.battery(BUTTON_BATTERY_NORMAL)) } + }, + { + min_api_version = 14 } ) @@ -2772,6 +2970,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound5", capabilities.battery.battery(BUTTON_BATTERY_LOW)) } + }, + { + min_api_version = 14 } ) @@ -2797,6 +2998,9 @@ test.register_message_test( direction = "send", message = mock_siren_with_buttons:generate_test_message("sound5", capabilities.battery.battery(BUTTON_BATTERY_NORMAL)) } + }, + { + min_api_version = 14 } ) @@ -2830,7 +3034,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -2847,7 +3054,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("sound5", capabilities.battery.battery(BUTTON_BATTERY_LOW)) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_ecolink_wireless_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_ecolink_wireless_siren.lua index f20e5c6074..ab6fab08a9 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_ecolink_wireless_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_ecolink_wireless_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -76,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 14 } ) @@ -111,6 +104,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("siren1", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 14 } ) @@ -146,6 +142,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("siren2", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 14 } ) @@ -171,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 14 } ) @@ -206,6 +208,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("siren1", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 14 } ) @@ -241,6 +246,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("siren2", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 14 } ) @@ -276,7 +284,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 4 } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -300,7 +311,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Receiving the both command from siren1 component should generate the correct commands including delayed commands", @@ -331,7 +345,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -355,7 +372,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -378,7 +398,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {4}}) ) ) - end + end, + { + min_api_version = 14 + } ) @@ -402,7 +425,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {dst_channels = {1}}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Receiving the off command from siren1 should generate the correct commands", @@ -424,7 +450,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {2}}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Receiving the off command from siren2 should generate the correct commands", @@ -446,7 +475,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {3}}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( "Receiving the off command from siren3 should generate the correct commands", @@ -468,7 +500,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {4}}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_fortrezz_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_fortrezz_siren.lua index e23b6d3caf..90a01396c8 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_fortrezz_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_fortrezz_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -69,7 +59,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.alarm.alarm.both({}))) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.switch.switch.on({}))) - end + end, + { + min_api_version = 14 + } ) @@ -100,7 +93,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.alarm.alarm.siren({}))) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.switch.switch.on({}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -130,7 +126,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.alarm.alarm.strobe({}))) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.switch.switch.on({}))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -160,7 +159,10 @@ test.register_coroutine_test( }) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.alarm.alarm.off({}))) test.socket.capability:__expect_send(mock_siren:generate_test_message("main", capabilities.switch.switch.off({}))) - end + end, + { + min_api_version = 14 + } ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_philio_sound_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_philio_sound_siren.lua index 23ad20a1fd..06255805f0 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_philio_sound_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_philio_sound_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" @@ -73,7 +63,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -103,7 +94,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -143,7 +135,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -174,7 +167,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -206,7 +200,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -228,7 +223,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -250,7 +246,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -282,7 +279,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_siren:generate_test_message("main", capabilities.chime.chime.off()) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -306,7 +306,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -328,7 +331,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -352,7 +358,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -376,7 +385,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -400,7 +412,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -424,7 +439,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -442,7 +460,10 @@ test.register_coroutine_test( Basic:Set({value=0x00}) ) ) - end + end, + { + min_api_version = 14 + } ) do @@ -461,7 +482,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -506,7 +531,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -528,7 +557,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-siren/src/test/test_utilitech_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_utilitech_siren.lua index 4c24c069ad..0632779570 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_utilitech_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_utilitech_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -51,6 +41,9 @@ test.register_message_test( direction = "receive", message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Battery:Report({ battery_level = 0x00 })) } } + }, + { + min_api_version = 14 } ) @@ -68,7 +61,10 @@ test.register_coroutine_test( mock_siren, Battery:Get({}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_yale_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_yale_siren.lua index 072a55c3d3..517947dfef 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_yale_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_yale_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 14 } ) @@ -80,6 +73,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 14 } ) @@ -115,7 +111,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) @@ -151,7 +150,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -186,7 +188,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -220,7 +225,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -253,7 +261,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -298,7 +309,10 @@ test.register_coroutine_test( mock_siren, Configuration:Get({parameter_number = 4}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -315,7 +329,10 @@ test.register_coroutine_test( mock_siren, Battery:Get({}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zipato_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_zipato_siren.lua index fbfbdd2db7..6c5672928b 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zipato_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zipato_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -71,7 +61,10 @@ test.register_coroutine_test( mock_siren, Basic:Set({value=0x00}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -99,7 +92,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -116,7 +112,10 @@ test.register_coroutine_test( mock_siren, Battery:Get({}) )) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -134,6 +133,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 14 } ) @@ -152,6 +154,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.both()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua b/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua new file mode 100644 index 0000000000..c7d5723d64 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua @@ -0,0 +1,717 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) +local SoundSwitch = (require "st.zwave.CommandClass.SoundSwitch")({ version = 1 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 8 }) +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) +local t_utils = require "integration_test.utils" + +local siren_endpoints = { + { + command_classes = { + { value = zw.SOUND_SWITCH }, + { value = zw.NOTIFICATION }, + { value = zw.VERSION }, + { value = zw.BASIC } + } + } +} + +--- { manufacturerId = 0x027A, productType = 0x0004, productId = 0x0369 } -- Zooz ZSE50 Siren & Chime +local mock_siren = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("zooz-zse50.yml"), + zwave_endpoints = siren_endpoints, + zwave_manufacturer_id = 0x027A, + zwave_product_type = 0x0004, + zwave_product_id = 0x0369, +}) + +local tones_list = { + [1] = { name = "test_tone1", duration = 2 }, + [2] = { name = "test_tone2", duration = 4 } +} + +local function test_init() + -- Initialize some fields to help with testing + mock_siren:set_field("TONE_DEFAULT", 1, { persist = true }) + mock_siren:set_field("TOTAL_TONES", 2, { persist = true }) + mock_siren:set_field("TONES_LIST", tones_list, { persist = true }) + + test.mock_device.add_test_device(mock_siren) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "init should rebuild tones when tone cache is missing", + function() + mock_siren:set_field("TONES_LIST", nil) + mock_siren:set_field("TONE_DEFAULT", nil) + + test.socket.device_lifecycle:__queue_receive({ mock_siren.id, "init" }) + test.socket.capability:__expect_send( + mock_siren:generate_test_message("main", capabilities.mode.mode("Rebuild List")) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonesNumberGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "added should set startup volume and refresh", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zwave:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_siren.id, "added" }) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 10 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Version:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationGet({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "infoChanged should update config and send delayed Basic Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.device_lifecycle:__queue_receive( + mock_siren:generate_info_changed({ preferences = { ledColor = 255 } }) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Configuration:Set({ parameter_number = 7, size = 1, configuration_value = -1 }) + ) + ) + + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Set({ value = 0x00 }) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "infoChanged should update playbackDuration and send delayed Basic Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.device_lifecycle:__queue_receive( + mock_siren:generate_info_changed({ preferences = { playbackDuration = 90 } }) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Configuration:Set({ parameter_number = 2, size = 2, configuration_value = 90 }) + ) + ) + + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Set({ value = 0x00 }) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Version report should update firmware version", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Version:Report({ + application_version = 2, + application_sub_version = 5 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.firmwareUpdate.currentVersion({ value = "2.05" })) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Notification report AC_MAINS_DISCONNECTED should set power source to battery", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.AC_MAINS_DISCONNECTED + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Notification report AC_MAINS_RE_CONNECTED should set power source to mains", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.AC_MAINS_RE_CONNECTED + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "SoundSwitch ConfigurationReport should update volume", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ConfigurationReport({ + volume = 75, + default_tone_identifer = 5 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.audioVolume.volume(75)) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "SoundSwitch TonesNumberReport should request info on each tone", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonesNumberReport({ + supported_tones = 2 + })) } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ToneInfoGet({ tone_identifier = 1 }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ToneInfoGet({ tone_identifier = 2 }) + ) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "SoundSwitch ToneInfoReport should update supported modes when all tones received", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ToneInfoReport({ + tone_identifier = 1, + name = "test_tone1", + tone_duration = 2 + })) } + }, + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ToneInfoReport({ + tone_identifier = 2, + name = "test_tone2", + tone_duration = 4 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.supportedModes({ "Rebuild List", "Off", "1: test_tone1 (2s)", "2: test_tone2 (4s)" })) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.supportedArguments({ "Off", "1: test_tone1 (2s)", "2: test_tone2 (4s)" })) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "SoundSwitch TonePlayReport for tone 1 should set alarm on, chime on, and mode to tone name", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonePlayReport({ + tone_identifier = 1 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.both()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.chime()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("1: test_tone1 (2s)")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "SoundSwitch TonePlayReport for tone 0 should set alarm off, chime off, and mode Off", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonePlayReport({ + tone_identifier = 0 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("Off")) + } + }, + { + min_api_version = 14 + } +) + +test.register_message_test( + "Basic report 0x00 should be handled as alarm off, chime off, and mode Off", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_siren.id, + zw_test_utils.zwave_test_build_receive_command(Basic:Report({ value = 0 })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("Off")) + } + }, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "volumeUp should increase volume by 2", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "volumeUp", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 52 }) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "volumeUp should decrease volume by 2", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "volumeDown", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 48 }) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "setVolume should set volume to specified value", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "setVolume", args = { 75 } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 75 }) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "alarm.both() should send TonePlaySet with default tone and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "alarm", component = "main", command = "both", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0xFF }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "alarm.off() should send TonePlaySet with tone 0x00 and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "chime.chime() should send TonePlaySet with default tone and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "chime", component = "main", command = "chime", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0xFF }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "chime.off() should send TonePlaySet with tone 0x00 and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "chime", component = "main", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "setMode should play the specified tone", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "1: test_tone1 (2s)" } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 1 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "setMode to Off should turn off the tone", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "Off" } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "setMode to Rebuild List should emit mode and send TonesNumberGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "Rebuild List" } } + }) + test.socket.capability:__expect_send( + mock_siren:generate_test_message("main", capabilities.mode.mode("Rebuild List")) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonesNumberGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "refresh should send a series of Z-Wave Gets", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Version:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationGet({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 14 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zwave_multifunctional-siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_zwave_multifunctional-siren.lua index c0d58bb952..27a2833d17 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zwave_multifunctional-siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zwave_multifunctional-siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -60,6 +50,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) } + }, + { + min_api_version = 14 } ) @@ -79,6 +72,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -98,6 +94,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({value = 25})) } + }, + { + min_api_version = 14 } ) @@ -117,6 +116,9 @@ test.register_message_test( direction = "send", message = mock_siren:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 25, unit = 'C'})) } + }, + { + min_api_version = 14 } ) @@ -142,7 +144,10 @@ test.register_coroutine_test( Basic:Get({}) )) mock_siren:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zwave_notification_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_zwave_notification_siren.lua index 73de507946..9fa63cfcba 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zwave_notification_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zwave_notification_siren.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -59,6 +49,9 @@ test.register_message_test( direction = "send", message = mock_siren_notification:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -83,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_siren_notification:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -102,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_siren_notification:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -126,6 +125,9 @@ test.register_message_test( direction = "send", message = mock_siren_notification:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zwave_siren.lua b/drivers/SmartThings/zwave-siren/src/test/test_zwave_siren.lua index 75713b89b7..2311a1dafa 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zwave_siren.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zwave_siren.lua @@ -1,4 +1,4 @@ ----@diagnostic disable: param-type-mismatch, undefined-field +-- Copyright 2022 SmartThings, Inc. -- Copyright 2022 SmartThings -- -- Licensed under the Apache License, Version 2.0 (the "License"); @@ -82,6 +82,9 @@ test.register_message_test( direction = "send", message = mock_siren_basic:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -105,6 +108,9 @@ test.register_message_test( direction = "send", message = mock_siren_basic:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -128,6 +134,9 @@ test.register_message_test( direction = "send", message = mock_siren_basic:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -151,6 +160,9 @@ test.register_message_test( direction = "send", message = mock_siren_basic:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -174,6 +186,9 @@ test.register_message_test( direction = "send", message = mock_siren_switch_binary:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -197,6 +212,9 @@ test.register_message_test( direction = "send", message = mock_siren_switch_binary:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -220,6 +238,9 @@ test.register_message_test( direction = "send", message = mock_siren_switch_binary:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -243,6 +264,9 @@ test.register_message_test( direction = "send", message = mock_siren_switch_binary:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -271,7 +295,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -299,7 +326,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) @@ -328,7 +358,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -356,7 +389,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -384,7 +420,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -422,7 +461,10 @@ test.register_coroutine_test( test.socket.device_lifecycle:__queue_receive({ mock_siren_basic.id, "added" }) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zwave_sound_sensor.lua b/drivers/SmartThings/zwave-siren/src/test/test_zwave_sound_sensor.lua index e88b1e1852..716c05ac0d 100644 --- a/drivers/SmartThings/zwave-siren/src/test/test_zwave_sound_sensor.lua +++ b/drivers/SmartThings/zwave-siren/src/test/test_zwave_sound_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -58,6 +48,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.detected()) } + }, + { + min_api_version = 14 } ) @@ -77,6 +70,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.not_detected()) } + }, + { + min_api_version = 14 } ) @@ -93,6 +89,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.soundSensor.sound.not_detected()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-siren/src/utilitech-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/utilitech-siren/can_handle.lua new file mode 100644 index 0000000000..72f6a963b9 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/utilitech-siren/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_utilitech_siren(opts, driver, device, ...) + local UTILITECH_MFR = 0x0060 + local UTILITECH_SIREN_PRODUCT_ID = 0x0001 + if device.zwave_manufacturer_id == UTILITECH_MFR and device.zwave_product_id == UTILITECH_SIREN_PRODUCT_ID then + return true, require("utilitech-siren") + end + return false +end + +return can_handle_utilitech_siren diff --git a/drivers/SmartThings/zwave-siren/src/utilitech-siren/init.lua b/drivers/SmartThings/zwave-siren/src/utilitech-siren/init.lua index 9e6818c6e4..0088fa6dd8 100644 --- a/drivers/SmartThings/zwave-siren/src/utilitech-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/utilitech-siren/init.lua @@ -1,29 +1,12 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local Basic = (require "st.zwave.CommandClass.Basic")({version=1,strict=true}) local Battery = (require "st.zwave.CommandClass.Battery")({version=1}) local BatteryDefaults = require "st.zwave.defaults.battery" -local UTILITECH_MFR = 0x0060 -local UTILITECH_SIREN_PRODUCT_ID = 0x0001 - -local function can_handle_utilitech_siren(opts, driver, device, ...) - return device.zwave_manufacturer_id == UTILITECH_MFR and device.zwave_product_id == UTILITECH_SIREN_PRODUCT_ID -end - local function device_added(self, device) device:send(Basic:Get({})) device:send(Battery:Get({})) @@ -39,7 +22,7 @@ end local utilitech_siren = { NAME = "utilitech-siren", - can_handle = can_handle_utilitech_siren, + can_handle = require("utilitech-siren.can_handle"), zwave_handlers = { [cc.BATTERY] = { [Battery.REPORT] = battery_report_handler diff --git a/drivers/SmartThings/zwave-siren/src/yale-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/yale-siren/can_handle.lua new file mode 100644 index 0000000000..1e29867936 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/yale-siren/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_yale_siren(opts, self, device, ...) + local YALE_MFR = 0x0129 + if device.zwave_manufacturer_id == YALE_MFR then + return true, require("yale-siren") + end + return false +end + +return can_handle_yale_siren diff --git a/drivers/SmartThings/zwave-siren/src/yale-siren/init.lua b/drivers/SmartThings/zwave-siren/src/yale-siren/init.lua index de548aee95..3757a2c3a9 100644 --- a/drivers/SmartThings/zwave-siren/src/yale-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/yale-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local capabilities = require "st.capabilities" @@ -20,12 +10,6 @@ local Configuration = (require "st.zwave.CommandClass.Configuration")({version=1 local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=1}) local preferencesMap = require "preferences" -local YALE_MFR = 0x0129 - -local function can_handle_yale_siren(opts, self, device, ...) - return device.zwave_manufacturer_id == YALE_MFR -end - local function siren_set_helper(device, value) device:send(Basic:Set({value = value})) local query_device = function() @@ -98,7 +82,7 @@ end local yale_siren = { NAME = "yale-siren", - can_handle = can_handle_yale_siren, + can_handle = require("yale-siren.can_handle"), capability_handlers = { [capabilities.alarm.ID] = { [capabilities.alarm.commands.both.NAME] = siren_on, diff --git a/drivers/SmartThings/zwave-siren/src/zipato-siren/can_handle.lua b/drivers/SmartThings/zwave-siren/src/zipato-siren/can_handle.lua new file mode 100644 index 0000000000..cb1170db2e --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zipato-siren/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zipato_siren(opts, driver, device, ...) + local ZIPATO_MFR = 0x0131 + if device.zwave_manufacturer_id == ZIPATO_MFR then + return true, require("zipato-siren") + end + return false +end + +return can_handle_zipato_siren diff --git a/drivers/SmartThings/zwave-siren/src/zipato-siren/init.lua b/drivers/SmartThings/zwave-siren/src/zipato-siren/init.lua index e22e643ae1..2af57daa19 100644 --- a/drivers/SmartThings/zwave-siren/src/zipato-siren/init.lua +++ b/drivers/SmartThings/zwave-siren/src/zipato-siren/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" local cc = require "st.zwave.CommandClass" @@ -18,13 +8,9 @@ local AlarmDefaults = require "st.zwave.defaults.alarm" local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) local Battery = (require "st.zwave.CommandClass.Battery")({version=1}) -local ZIPATO_MFR = 0x0131 local BASIC_AND_SWITCH_BINARY_REPORT_STROBE_LIMIT = 33 local BASIC_AND_SWITCH_BINARY_REPORT_SIREN_LIMIT = 66 -local function can_handle_zipato_siren(opts, driver, device, ...) - return device.zwave_manufacturer_id == ZIPATO_MFR -end local function basic_report_handler(driver, device, cmd) local value = cmd.args.value @@ -64,7 +50,7 @@ end local zipato_siren = { NAME = "zipato-siren", - can_handle = can_handle_zipato_siren, + can_handle = require("zipato-siren.can_handle"), capability_handlers = { [capabilities.alarm.ID] = { [capabilities.alarm.commands.both.NAME] = siren_on, diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua new file mode 100644 index 0000000000..90ce30f76b --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multifunctional_siren(opts, driver, device, ...) + local FINGERPRINTS = require("zooz-zse50.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zooz-zse50") + end + end + return false +end + +return can_handle_multifunctional_siren diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua new file mode 100644 index 0000000000..a8c37a04e6 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZSE50_FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x0004, productId = 0x0369 } -- Zooz ZSE50 Siren & Chime +} + +return ZSE50_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua new file mode 100644 index 0000000000..a1d58b262a --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua @@ -0,0 +1,367 @@ +-- Copyright 2026 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local preferencesMap = require "preferences" + +local log = require "log" +local st_utils = require "st.utils" +local capabilities = require "st.capabilities" +local defaults = require "st.zwave.defaults" + +local cc = require "st.zwave.CommandClass" +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 8 }) +local SoundSwitch = (require "st.zwave.CommandClass.SoundSwitch")({ version = 1 }) +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) + +--- @param self st.zwave.Driver +--- @param device st.zwave.Device +local function update_firmwareUpdate_capability(self, device, component, major, minor) + if device:supports_capability_by_id(capabilities.firmwareUpdate.ID, component.id) then + local fmtFirmwareVersion = string.format("%d.%02d", major, minor) + device:emit_component_event(component, capabilities.firmwareUpdate.currentVersion({ value = fmtFirmwareVersion })) + end +end + +--- Update the built in capability firmwareUpdate's currentVersion attribute with the +--- Zwave version information received during pairing of the device. +--- @param self st.zwave.Driver +--- @param device st.zwave.Device +local function updateFirmwareVersion(self, device) + local fw_major = (((device.st_store or {}).zwave_version or {}).firmware or {}).major + local fw_minor = (((device.st_store or {}).zwave_version or {}).firmware or {}).minor + if fw_major and fw_minor then + update_firmwareUpdate_capability(self, device, device.profile.components.main, fw_major, fw_minor) + else + device.log.warn("Firmware major or minor version not available.") + end +end + +local function getModeName(toneId, toneInfo) + return string.format("%s: %s (%ss)", toneId, toneInfo.name, toneInfo.duration) +end + +local function playTone(device, tone_id) + local tones_list = device:get_field("TONES_LIST") + local default_tone = device:get_field("TONE_DEFAULT") or 1 + local playbackMode = tonumber(device.preferences.playbackMode) + local duration = 0 + + if playbackMode == 1 then + duration = device.preferences.playbackDuration + elseif playbackMode == 2 then + duration = duration * device.preferences.playbackLoop + elseif tones_list ~= nil and tone_id > 0 then + if tone_id == 0xFF then + duration = tones_list[tonumber(default_tone)].duration + else + duration = tones_list[tonumber(tone_id)].duration + end + end + + log.info(string.format("Playing Tone: %s, playbackMode %s, duration %ss", tone_id, playbackMode, duration)) + + device:send(SoundSwitch:TonePlaySet({ tone_identifier = tone_id })) + device:send(SoundSwitch:TonePlayGet({})) + + local soundSwitch_refresh = function() + local chime = device:get_latest_state("main", capabilities.chime.ID, capabilities.chime.chime.NAME) + local mode = device:get_latest_state("main", capabilities.mode.ID, capabilities.mode.mode.NAME) + log.info(string.format("Running SoundSwitch Refresh: %s | %s", chime, mode)) + if chime ~= "off" or mode ~= "Off" then + device:send(SoundSwitch:TonePlayGet({})) + end + end + + if tone_id > 0 and playbackMode <= 2 then + local minDuration = math.max(duration, 4) + device.thread:call_with_delay(minDuration + 0.5, soundSwitch_refresh) + device.thread:call_with_delay(minDuration + 4, soundSwitch_refresh) + end + +end + +local function rebuildTones(device) + device:emit_event(capabilities.mode.mode("Rebuild List")) + device:send(SoundSwitch:TonesNumberGet({})) +end + +local function refresh_handler(self, device) + device:default_refresh() + device:send(Version:Get({})) + device:send(Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + })) + device:send(SoundSwitch:ConfigurationGet({})) + device:send(SoundSwitch:TonePlayGet({})) +end + +local function setMode_handler(self, device, command) + local mode_value = command.args.mode + local mode_split = string.find(mode_value, ":") + + if mode_split ~= nil then + mode_value = string.sub(mode_value, 1, mode_split - 1) + end + log.info(string.format("Command: setMode (%s)", mode_value)) + + if mode_value == 'Rebuild List' then + rebuildTones(device) + elseif mode_value == 'Off' then + playTone(device, 0x00) + else + playTone(device, tonumber(mode_value)) + end +end + +local function setVolume_handler(self, device, cmd) + local new_volume = st_utils.clamp_value(cmd.args.volume, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = new_volume })) +end + +local function volumeUp_handler(self, device, cmd) + local volume = device:get_latest_state("main", capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) or 50 + volume = st_utils.clamp_value(volume + 2, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = volume })) +end + +local function volumeDown_handler(self, device, cmd) + local volume = device:get_latest_state("main", capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) or 50 + volume = st_utils.clamp_value(volume - 2, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = volume })) +end + +local function tone_on(self, device) + playTone(device, 0xFF) +end + +local function tone_off(self, device) + playTone(device, 0x00) +end + +local function tones_number_report_handler(self, device, cmd) + local total_tones = cmd.args.supported_tones + + --Max 50 tones per Zooz settings + if total_tones > 50 then + total_tones = 50 + end + + local tones_list = { } + device:set_field("TOTAL_TONES", total_tones) + device:set_field("TONES_LIST_TMP", tones_list) + + --Get info on all tones + for tone = 1, total_tones do + device:send(SoundSwitch:ToneInfoGet({ tone_identifier = tone })) + end +end + +local function tone_info_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.tone_identifier) + local tone_name = cmd.args.name + local duration = cmd.args.tone_duration + local total_tones = device:get_field("TOTAL_TONES") + local tones_list = device:get_field("TONES_LIST_TMP") or {} + + tones_list[tone_id] = { name = tone_name, duration = duration } + device:set_field("TONES_LIST_TMP", tones_list) + + if tone_id >= total_tones or #tones_list >= total_tones then + log.info(string.format("Received info on all tones: tone_id %s, #tones_list %s, total_tones %s", tone_id, #tones_list, total_tones)) + + local tones_arguments = { "Off" } + for il, vl in ipairs(tones_list) do + table.insert(tones_arguments, getModeName(il, vl)) + end + + device:set_field("TONES_LIST", tones_list, { persist = true }) + device:emit_event(capabilities.mode.supportedModes({ "Rebuild List", table.unpack(tones_arguments) })) + device:emit_event(capabilities.mode.supportedArguments(tones_arguments)) + device:send(SoundSwitch:TonePlayGet({})) + end +end + +--- Handle when tone is played (TONE_PLAY_REPORT or BASIC_REPORT) +local function tone_playing(self, device, tone_id) + local tones_list = device:get_field("TONES_LIST") + + if tones_list == nil or tones_list == {} then + rebuildTones(device) + end + + if tone_id == 0 then + device:emit_event(capabilities.alarm.alarm.off()) + device:emit_event(capabilities.chime.chime.off()) + device:emit_event(capabilities.mode.mode("Off")) + else + local toneInfo = (tones_list or {})[tone_id] or { name = "Unknown", duration = "0" } + local modeName = getModeName(tone_id, toneInfo) + device:emit_event(capabilities.alarm.alarm.both()) + device:emit_event(capabilities.chime.chime.chime()) + device:emit_event(capabilities.mode.mode(modeName)) + end +end + +local function tone_play_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.tone_identifier) + tone_playing(self, device, tone_id) +end + +local function basic_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.value) + tone_playing(self, device, tone_id) +end + +--- Handle SoundSwitch Config Reports (volume) +local function soundSwitch_configuration_report(self, device, cmd) + local volume = st_utils.clamp_value(cmd.args.volume, 0, 100) + local default_tone = cmd.args.default_tone_identifer + device:emit_event(capabilities.audioVolume.volume(volume)) + device:set_field("TONE_DEFAULT", default_tone, { persist = true }) +end + +--- Handle power source changes +local function notification_report_handler(self, device, cmd) + if cmd.args.notification_type == Notification.notification_type.POWER_MANAGEMENT then + local event = cmd.args.event + local powerManagement = Notification.event.power_management + + if event == powerManagement.AC_MAINS_DISCONNECTED then + device:emit_event(capabilities.powerSource.powerSource.battery()) + elseif event == powerManagement.AC_MAINS_RE_CONNECTED or event == powerManagement.STATE_IDLE then + device:emit_event(capabilities.powerSource.powerSource.mains()) + end + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd st.zwave.CommandClass.Version.Report +local function version_report_handler(driver, device, cmd) + local major = cmd.args.application_version + local minor = cmd.args.application_sub_version + + -- Update the built in firmware capability, if available + update_firmwareUpdate_capability(driver, device, device.profile.components.main, major, minor) +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +local function device_init(driver, device) + if (device:get_field("TONES_LIST") == nil or device:get_field("TONE_DEFAULT") == nil) then + rebuildTones(device) + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +local function device_added(driver, device) + device:send(SoundSwitch:ConfigurationSet({ volume = 10 })) + updateFirmwareVersion(driver, device) + device:refresh() +end + +--- Handle preference changes (same as default but added hack for unsigned parameters) +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param event table +--- @param args +local function info_changed(driver, device, event, args) + local preferences = preferencesMap.get_device_parameters(device) + + if preferences then + local did_configuration_change = false + for id, value in pairs(device.preferences) do + if args.old_st_store.preferences[id] ~= value and preferences[id] then + local new_parameter_value = preferencesMap.to_numeric_value(device.preferences[id]) + --Hack to convert to signed integer + local size_factor = math.floor(256 ^ preferences[id].size) + if new_parameter_value >= (size_factor / 2) then + new_parameter_value = new_parameter_value - size_factor + end + --END Hack + device:send(Configuration:Set({ parameter_number = preferences[id].parameter_number, size = preferences[id].size, configuration_value = new_parameter_value })) + did_configuration_change = true + end + end + + if did_configuration_change then + local delayed_command = function() + device:send(Basic:Set({ value = 0x00 })) + end + device.thread:call_with_delay(1, delayed_command) + end + + end +end + +local zooz_zse50 = { + NAME = "Zooz ZSE50", + can_handle = require("zooz-zse50.can_handle"), + + supported_capabilities = { + capabilities.battery, + capabilities.chime, + capabilities.mode, + capabilities.audioVolume, + capabilities.powerSource, + capabilities.firmwareUpdate, + capabilities.configuration, + capabilities.refresh + }, + + zwave_handlers = { + [cc.BASIC] = { + [Basic.REPORT] = basic_report_handler + }, + [cc.SOUND_SWITCH] = { + [SoundSwitch.TONES_NUMBER_REPORT] = tones_number_report_handler, + [SoundSwitch.TONE_INFO_REPORT] = tone_info_report_handler, + [SoundSwitch.TONE_PLAY_REPORT] = tone_play_report_handler, + [SoundSwitch.CONFIGURATION_REPORT] = soundSwitch_configuration_report + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + }, + [cc.VERSION] = { + [Version.REPORT] = version_report_handler + } + }, + + capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = setMode_handler + }, + [capabilities.audioVolume.ID] = { + [capabilities.audioVolume.commands.setVolume.NAME] = setVolume_handler, + [capabilities.audioVolume.commands.volumeUp.NAME] = volumeUp_handler, + [capabilities.audioVolume.commands.volumeDown.NAME] = volumeDown_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + }, + [capabilities.alarm.ID] = { + [capabilities.alarm.commands.both.NAME] = tone_on, + [capabilities.alarm.commands.off.NAME] = tone_off + }, + [capabilities.chime.ID] = { + [capabilities.chime.commands.chime.NAME] = tone_on, + [capabilities.chime.commands.off.NAME] = tone_off + }, + + }, + + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed + } +} + +defaults.register_for_default_handlers(zooz_zse50, zooz_zse50.supported_capabilities) + +return zooz_zse50 diff --git a/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/can_handle.lua b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/can_handle.lua new file mode 100644 index 0000000000..c3fb8b343e --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_zwave_sound_sensor(opts, driver, device, ...) + local FINGERPRINTS = require("zwave-sound-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zwave-sound-sensor") + end + end + return false +end + +return can_handle_zwave_sound_sensor diff --git a/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/fingerprints.lua new file mode 100644 index 0000000000..e0c8ef4762 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZWAVE_SOUND_SENSOR_FINGERPRINTS = { + { manufacturerId = 0x014A, productType = 0x0005, productId = 0x000F } --Ecolink Firefighter +} + +return ZWAVE_SOUND_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/init.lua b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/init.lua index c591f8a89b..c50153ec26 100644 --- a/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/init.lua +++ b/drivers/SmartThings/zwave-siren/src/zwave-sound-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -18,23 +8,12 @@ local cc = require "st.zwave.CommandClass" --- @type st.zwave.CommandClass.Alarm local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 2 }) -local ZWAVE_SOUND_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x014A, productType = 0x0005, productId = 0x000F } --Ecolink Firefighter -} --- Determine whether the passed device is zwave-sound-sensor --- --- @param driver Driver driver instance --- @param device Device device isntance --- @return boolean true if the device proper, else false -local function can_handle_zwave_sound_sensor(opts, driver, device, ...) - for _, fingerprint in ipairs(ZWAVE_SOUND_SENSOR_FINGERPRINTS) do - if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end --- Default handler for alarm command class reports --- @@ -73,7 +52,7 @@ local zwave_sound_sensor = { added = added_handler, }, NAME = "zwave sound sensor", - can_handle = can_handle_zwave_sound_sensor, + can_handle = require("zwave-sound-sensor.can_handle"), } return zwave_sound_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/apiv6_bugfix/init.lua deleted file mode 100644 index 0204b7b2d5..0000000000 --- a/drivers/SmartThings/zwave-smoke-alarm/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,26 +0,0 @@ -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - return version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION -end - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = can_handle -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/can_handle.lua b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/can_handle.lua new file mode 100644 index 0000000000..4a3f51183b --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_smoke_sensor(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("fibaro-smoke-sensor.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("fibaro-smoke-sensor") + end + end + return false +end + +return can_handle_fibaro_smoke_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/fingerprints.lua b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/fingerprints.lua new file mode 100644 index 0000000000..81c04eccfc --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_SMOKE_SENSOR_FINGERPRINTS = { + { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x1002 }, -- Fibaro Smoke Sensor + { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x1003 }, -- Fibaro Smoke Sensor + { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x3002 }, -- Fibaro Smoke Sensor + { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x4002 } -- Fibaro Smoke Sensor +} + +return FIBARO_SMOKE_SENSOR_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/init.lua index a4d62fa1a4..bf7d554613 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/fibaro-smoke-sensor/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -24,26 +14,12 @@ local WakeUp = (require "st.zwave.CommandClass.WakeUp")({version=1}) local FIBARO_SMOKE_SENSOR_WAKEUP_INTERVAL = 21600 --seconds -local FIBARO_SMOKE_SENSOR_FINGERPRINTS = { - { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x1002 }, -- Fibaro Smoke Sensor - { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x1003 }, -- Fibaro Smoke Sensor - { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x3002 }, -- Fibaro Smoke Sensor - { manufacturerId = 0x010F, productType = 0x0C02, productId = 0x4002 } -- Fibaro Smoke Sensor -} --- Determine whether the passed device is fibaro smoke sensro --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is fibaro smoke sensor -local function can_handle_fibaro_smoke_sensor(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(FIBARO_SMOKE_SENSOR_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function device_added(self, device) device:send(WakeUp:IntervalSet({node_id = self.environment_info.hub_zwave_id, seconds = FIBARO_SMOKE_SENSOR_WAKEUP_INTERVAL})) @@ -76,7 +52,7 @@ local fibaro_smoke_sensor = { added = device_added }, NAME = "fibaro smoke sensor", - can_handle = can_handle_fibaro_smoke_sensor, + can_handle = require("fibaro-smoke-sensor.can_handle"), health_check = false, } diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/init.lua index 0d0a5d1bfd..1ee506f453 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -83,18 +73,14 @@ local driver_template = { capabilities.temperatureAlarm, capabilities.temperatureMeasurement }, - sub_drivers = { - require("zwave-smoke-co-alarm-v1"), - require("zwave-smoke-co-alarm-v2"), - require("fibaro-smoke-sensor"), - require("apiv6_bugfix"), - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { init = device_init, infoChanged = info_changed, doConfigure = do_configure, added = device_added - } + }, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-smoke-alarm/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/preferences.lua b/drivers/SmartThings/zwave-smoke-alarm/src/preferences.lua index 25606a5255..55fa70d48b 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/preferences.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/preferences.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { FIBARO = { diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/sub_drivers.lua b/drivers/SmartThings/zwave-smoke-alarm/src/sub_drivers.lua new file mode 100644 index 0000000000..39901aaf21 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/sub_drivers.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-smoke-co-alarm-v1"), + lazy_load_if_possible("zwave-smoke-co-alarm-v2"), + lazy_load_if_possible("fibaro-smoke-sensor"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_co_sensor_zw5.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_co_sensor_zw5.lua index 4f3b08d3f3..7031ffb88b 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_co_sensor_zw5.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_co_sensor_zw5.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -78,7 +68,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = ACOUSTIC_SIGNALS, configuration_value = EXCEEDING_THE_TEMPERATURE}) )) mock_fibaro_CO_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -94,6 +87,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -119,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -144,6 +143,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 70.7, unit = 'F' })) } + }, + { + min_api_version = 14 } ) @@ -168,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) } + }, + { + min_api_version = 14 } ) @@ -192,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) } + }, + { + min_api_version = 14 } ) @@ -216,6 +224,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 14 } ) @@ -240,6 +251,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) } + }, + { + min_api_version = 14 } ) @@ -263,6 +277,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.tested()) } + }, + { + min_api_version = 14 } ) @@ -288,6 +305,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) } + }, + { + min_api_version = 14 } ) @@ -312,6 +332,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 14 } ) @@ -336,6 +359,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_CO_sensor:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) } + }, + { + min_api_version = 14 } ) @@ -365,7 +391,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -472,7 +499,10 @@ test.register_coroutine_test( Configuration:Report({ parameter_number = 2, configuration_value = 3 }) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_smoke_sensor.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_smoke_sensor.lua index ebb50eaa6e..c877d18e47 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_smoke_sensor.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_fibaro_smoke_sensor.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -158,7 +148,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number=32, size=2, configuration_value=4320}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -191,7 +184,10 @@ test.register_coroutine_test( Battery:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -211,7 +207,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_alarm_v1.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_alarm_v1.lua index fd2996b9d1..af2588b959 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_alarm_v1.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_alarm_v1.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -62,6 +52,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -81,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -100,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 14 } ) @@ -119,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) } + }, + { + min_api_version = 14 } ) @@ -161,6 +163,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) }, + }, + { + min_api_version = 14 } ) @@ -180,6 +185,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -199,6 +207,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_co_detector.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_co_detector.lua index e42edd4828..3bf9d563a1 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_co_detector.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_co_detector.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -63,6 +53,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 14 } ) @@ -82,6 +75,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) }, + }, + { + min_api_version = 14 } ) test.register_message_test( @@ -100,6 +96,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) }, + }, + { + min_api_version = 14 } ) @@ -119,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.detected()) } + }, + { + min_api_version = 14 } ) @@ -138,6 +140,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.tested()) } + }, + { + min_api_version = 14 } ) @@ -157,6 +162,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) } + }, + { + min_api_version = 14 } ) @@ -171,6 +179,9 @@ test.register_message_test( alarm_level = 1 })) } }, + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua index 95121b1673..838527222b 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/test/test_zwave_smoke_detector.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -64,6 +54,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -83,6 +76,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) }, + }, + { + min_api_version = 14 } ) @@ -102,6 +98,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) }, + }, + { + min_api_version = 14 } ) @@ -121,6 +120,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -140,6 +142,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -159,6 +164,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.tested()) } + }, + { + min_api_version = 14 } ) @@ -178,6 +186,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -192,6 +203,9 @@ test.register_message_test( alarm_level = 1 })) } }, + }, + { + min_api_version = 14 } ) @@ -238,7 +252,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -258,6 +273,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 14 } ) @@ -277,6 +295,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 14 } ) @@ -296,6 +317,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 14 } ) @@ -315,6 +339,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) } + }, + { + min_api_version = 14 } ) @@ -334,6 +361,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.cleared()) } + }, + { + min_api_version = 14 } ) @@ -376,7 +406,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/can_handle.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/can_handle.lua new file mode 100644 index 0000000000..dc78d0e4ac --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_v1_alarm(opts, driver, device, cmd, ...) + -- The default handlers for the Alarm/Notification command class(es) for the + -- Smoke Detector and Carbon Monoxide Detector only handles V3 and up. + if opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version < 3 then + return true, require("zwave-smoke-co-alarm-v1") + end + return false +end + +return can_handle_v1_alarm diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/init.lua index 923c516167..8e3cc4e267 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v1/init.lua @@ -1,16 +1,5 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -23,17 +12,6 @@ local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 1 }) -- manufacturerId = 0x0138, productType = 0x0001, productId = 0x0002 -- First Alert Smoke & CO Detector -- manufacturerId = 0x0138, productType = 0x0001, productId = 0x0003 -- First Alert Smoke & CO Detector ---- Determine whether the passed device only supports V1 or V2 of the Alarm command class ---- ---- @param driver st.zwave.Driver ---- @param device st.zwave.Device ---- @return boolean true if the device is smoke co alarm -local function can_handle_v1_alarm(opts, driver, device, cmd, ...) - -- The default handlers for the Alarm/Notification command class(es) for the - -- Smoke Detector and Carbon Monoxide Detector only handles V3 and up. - return opts.dispatcher_class == "ZwaveDispatcher" and cmd ~= nil and cmd.version ~= nil and cmd.version < 3 -end - --- Default handler for alarm command class reports --- --- This converts alarm V1 reports to correct smoke events @@ -75,7 +53,7 @@ local zwave_alarm = { } }, NAME = "Z-Wave smoke and CO alarm V1", - can_handle = can_handle_v1_alarm, + can_handle = require("zwave-smoke-co-alarm-v1.can_handle"), } return zwave_alarm diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/can_handle.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/can_handle.lua new file mode 100644 index 0000000000..6666e7abc2 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_v2_alarm(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("zwave-smoke-co-alarm-v2.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zwave-smoke-co-alarm-v2") + end + end + return false +end + +return can_handle_v2_alarm diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/can_handle.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/can_handle.lua new file mode 100644 index 0000000000..baa0d7bbf0 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_co_sensor(opts, driver, device, cmd, ...) + local FINGERPRINTS = require("zwave-smoke-co-alarm-v2.fibaro-co-sensor-zw5.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zwave-smoke-co-alarm-v2.fibaro-co-sensor-zw5") + end + end + return false +end + +return can_handle_fibaro_co_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/fingerprints.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/fingerprints.lua new file mode 100644 index 0000000000..37fb1f508c --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_CO_SENSORS_FINGERPRINTS = { + { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1000 }, -- Fibaro CO Sensor + { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1001 } -- Fibaro CO Sensor +} + +return FIBARO_CO_SENSORS_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/init.lua index bde1cbc877..0559b83983 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local cc = require "st.zwave.CommandClass" local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 2 }) @@ -21,10 +11,6 @@ local TAMPERING_AND_EXCEEDING_THE_TEMPERATURE = 3 local ACOUSTIC_SIGNALS = 4 local EXCEEDING_THE_TEMPERATURE = 2 -local FIBARO_CO_SENSORS_FINGERPRINTS = { - { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1000 }, -- Fibaro CO Sensor - { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1001 } -- Fibaro CO Sensor -} local function parameterNumberToParameterName(preferences,parameterNumber) for id, parameter in pairs(preferences) do @@ -40,14 +26,6 @@ end --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is smoke co alarm -local function can_handle_fibaro_co_sensor(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(FIBARO_CO_SENSORS_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local function update_preferences(self, device, args) local preferences = preferencesMap.get_device_parameters(device) @@ -108,7 +86,7 @@ local fibaro_co_sensor = { init = device_init, infoChanged = info_changed }, - can_handle = can_handle_fibaro_co_sensor + can_handle = require("zwave-smoke-co-alarm-v2.fibaro-co-sensor-zw5.can_handle"), } return fibaro_co_sensor diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fingerprints.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fingerprints.lua new file mode 100644 index 0000000000..8dc021cc28 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SMOKE_CO_ALARM_V2_FINGERPRINTS = { + { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1000 }, -- Fibaro CO Sensor + { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1001 } -- Fibaro CO Sensor +} + +return SMOKE_CO_ALARM_V2_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/init.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/init.lua index b49b010cdb..5e69f7108d 100644 --- a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/init.lua +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -20,24 +11,12 @@ local Alarm = (require "st.zwave.CommandClass.Alarm")({ version = 2 }) --- @type st.zwave.CommandClass.Notification local Notification = (require "st.zwave.CommandClass.Notification")({version=3}) -local SMOKE_CO_ALARM_V2_FINGERPRINTS = { - { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1000 }, -- Fibaro CO Sensor - { manufacturerId = 0x010F, productType = 0x1201, productId = 0x1001 } -- Fibaro CO Sensor -} --- Determine whether the passed device is Smoke Alarm --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is smoke co alarm -local function can_handle_v2_alarm(opts, driver, device, cmd, ...) - for _, fingerprint in ipairs(SMOKE_CO_ALARM_V2_FINGERPRINTS) do - if device:id_match( fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then - return true - end - end - return false -end local device_added = function(self, device) device:emit_event(capabilities.carbonMonoxideDetector.carbonMonoxide.clear()) @@ -94,13 +73,11 @@ local zwave_alarm = { } }, NAME = "Z-Wave smoke and CO alarm V2", - can_handle = can_handle_v2_alarm, + can_handle = require("zwave-smoke-co-alarm-v2.can_handle"), lifecycle_handlers = { added = device_added }, - sub_drivers = { - require("zwave-smoke-co-alarm-v2/fibaro-co-sensor-zw5") - } + sub_drivers = require("zwave-smoke-co-alarm-v2.sub_drivers"), } return zwave_alarm diff --git a/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/sub_drivers.lua b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/sub_drivers.lua new file mode 100644 index 0000000000..0699743371 --- /dev/null +++ b/drivers/SmartThings/zwave-smoke-alarm/src/zwave-smoke-co-alarm-v2/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("zwave-smoke-co-alarm-v2.fibaro-co-sensor-zw5"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-switch/fingerprints.yml b/drivers/SmartThings/zwave-switch/fingerprints.yml index d90a0c6b76..77aba90d2c 100644 --- a/drivers/SmartThings/zwave-switch/fingerprints.yml +++ b/drivers/SmartThings/zwave-switch/fingerprints.yml @@ -56,6 +56,12 @@ zwaveManufacturer: productType: 0x0003 productId: 0x0001 deviceProfileName: inovelli-dimmer + - id: "Inovelli/VZW31-SN" + deviceLabel: Inovelli Dimmer Red Series + manufacturerId: 0x031E + productType: 0x0015 + productId: 0x0001 + deviceProfileName: inovelli-dimmer-vzw31-sn - id: "Inovelli/VZW32-SN" deviceLabel: Inovelli mmWave Dimmer Red Series manufacturerId: 0x031E @@ -559,7 +565,7 @@ zwaveManufacturer: productId: 0xC001 deviceProfileName: smartplug-binary - id: 0312/FF00/FF07 - deviceLabel: Minoston Outlet + deviceLabel: Smart Plug manufacturerId: 0x0312 productType: 0xFF00 productId: 0xFF07 @@ -868,11 +874,23 @@ zwaveManufacturer: productId: 0x1000 deviceProfileName: metering-switch - id: 0312/FF00/FF0E - deviceLabel: Minoston Outlet + deviceLabel: Smart Plug manufacturerId: 0x0312 productType: 0xFF00 productId: 0xFF0E deviceProfileName: metering-switch + - id: "0312/FF01/FF97" + deviceLabel: IP65 Outdoor Zwave Smart Plug with 2 Individual Outlets + manufacturerId: 0x0312 + 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 @@ -916,6 +934,19 @@ zwaveManufacturer: manufacturerId: 0x010F productType: 0x0102 deviceProfileName: fibaro-dimmer-2 + - id: 013C/0005/008A + deviceLabel: Philio Dimmer Switch PAD19 + manufacturerId: 0x013C + productType: 0x0005 + productId: 0x008A + deviceProfileName: switch-level +#Zooz + - id: "Zooz/ZEN05" + deviceLabel: Zooz Outdoor Plug ZEN05 + manufacturerId: 0x027A + productType: 0x7000 + productId: 0xB001 + deviceProfileName: zooz-zen05-plug #Shelly/Qubino - id: 1120/2/137 deviceLabel: Wave Plug UK @@ -940,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-switch/profiles/inovelli-dimmer-vzw31-sn.yml b/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml new file mode 100644 index 0000000000..e2da4a3239 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml @@ -0,0 +1,284 @@ +name: inovelli-dimmer-vzw31-sn +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +- id: button1 + label: Down Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button2 + label: Up Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button3 + label: Config Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - name: "notificationChild" + title: "Add Child Device - Notification" + description: "Create Separate Child Device for Notification Control" + required: false + preferenceType: boolean + definition: + default: false + - name: "notificationType" + title: "Notification Effect" + description: "This is the notification effect used by the notification child device" + required: false + preferenceType: enumeration + definition: + options: + "255": "Clear" + "1": "Solid" + "2": "Fast Blink" + "3": "Slow Blink" + "4": "Pulse" + "5": "Chase" + "6": "Open/Close" + "7": "Small-to-Big" + "8": "Aurora" + "9": "Slow Falling" + "10": "Medium Falling" + "11": "Fast Falling" + "12": "Slow Rising" + "13": "Medium Rising" + "14": "Fast Rising" + "15": "Medium Blink" + "16": "Slow Chase" + "17": "Fast Chase" + "18": "Fast Siren" + "19": "Slow Siren" + default: 1 + - name: "parameter158" + title: "158. Switch Mode" + description: "Use as a Dimmer or an On/Off switch" + required: true + preferenceType: enumeration + definition: + options: + "0": "Dimmer (default)" + "1": "On/Off" + default: 0 + - name: "parameter52" + title: "52. Smart Bulb Mode" + description: "For use with Smart Bulbs that need constant power and are controlled via commands rather than power. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: true + preferenceType: enumeration + definition: + options: + "0": "Disabled (default)" + "1": "Smart Bulb Mode" + default: 0 + - name: "parameter22" + title: "22. Aux Switch Type" + description: "Set the Aux switch type. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: true + preferenceType: enumeration + definition: + options: + "0": "None (default)" + "1": "3-Way Dumb Switch" + "2": "3-Way Aux Switch" + "3": "Single Pole Full Sine Wave" + default: 0 + - name: "parameter1" + title: "1. Dimming Speed (Remote)" + description: "This changes the speed that the light dims up when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + Default=25 (2500ms or 2.5s)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 25 + - name: "parameter2" + title: "2. Dimming Speed (Local)" + description: "This changes the speed that the light dims up when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter3" + title: "3. Ramp Rate (Remote)" + description: "This changes the speed that the light turns on when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter4" + title: "4. Ramp Rate (Local)" + description: "This changes the speed that the light turns on when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 3)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter9" + title: "9. Minimum Level" + description: "The minimum level that the light can be dimmed. Useful when the user has a light that does not turn on or flickers at a lower level." + required: true + preferenceType: number + definition: + minimum: 1 + maximum: 99 + default: 1 + - name: "parameter10" + title: "10. Maximum Level" + description: "The maximum level that the light can be dimmed. Useful when the user wants to limit the maximum brighness." + required: true + preferenceType: number + definition: + minimum: 2 + maximum: 100 + default: 100 + - name: "parameter15" + title: "15. Level After Power Restored" + description: "The level the switch will return to when power is restored after power failure. + 0=Off + 1-100=Set Level + 101=Use previous level." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 101 + default: 101 + - name: "parameter18" + title: "18. Active Power Reports" + description: "Power level change that will result in a new power report being sent. + 0 = Disabled + 1-32767 = 0.1W-3276.7W." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 100 + - name: "parameter19" + title: "19. Periodic Power & Energy Reports" + description: "Time period between consecutive power & energy reports being sent (in seconds). The timer is reset after each report is sent." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 3600 + - name: "parameter20" + title: "20. Active Energy Reports" + description: "Energy level change that will result in a new energy report being sent. + 0 = Disabled + 1-32767 = 0.01kWh-327.67kWh." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 100 + - name: "parameter50" + title: "50. Button Press Delay" + description: "Adjust the delay used in scene control. 0=no delay (disables multi-tap scenes), 1=100ms, 2=200ms, 3=300ms, etc." + required: true + preferenceType: enumeration + definition: + options: + "0": "0ms" + "1": "100ms" + "2": "200ms" + "3": "300ms" + "4": "400ms" + "5": "500ms (default)" + "6": "600ms" + "7": "700ms" + "8": "800ms" + "9": "900ms" + default: 5 + - name: "parameter95" + title: "95. LED Indicator Color (w/On)" + description: "Set the color of the Full LED Indicator when the load is on." + required: true + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter96" + title: "96. LED Indicator Color (w/Off)" + description: "Set the color of the Full LED Indicator when the load is off." + required: true + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter97" + title: "97. LED Indicator Intensity (w/On)" + description: "Set the intensity of the Full LED Indicator when the load is on." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 50 + - name: "parameter98" + title: "98. LED Indicator Intensity (w/Off)" + description: "Set the intensity of the Full LED Indicator when the load is off." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 5 \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/profiles/zooz-zen05-plug.yml b/drivers/SmartThings/zwave-switch/profiles/zooz-zen05-plug.yml new file mode 100644 index 0000000000..43685ff2c7 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/zooz-zen05-plug.yml @@ -0,0 +1,90 @@ +name: zooz-zen05-plug +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: voltageMeasurement + version: 1 + - id: refresh + version: 1 + categories: + - name: SmartPlug +preferences: + - name: "driverInfo" + title: "Driver Info" + description: "This driver may have limited features, check the online Zooz documentation for information about custom drivers." + preferenceType: string + definition: + stringType: paragraph + #param 1 + - name: "ledMode" + title: "LED Indicator Behavior" + description: "Decide how the LED indicator behaves depending on the on/off status of the plug." + required: false + preferenceType: enumeration + definition: + options: + 0: "On when on, off when off *" + 1: "On when off, off when on" + 2: "Always off" + default: 0 + #param 7 + - name: "ledBrightness" + title: "LED Brightness" + description: "Choose the LED indicator brightness level." + required: false + preferenceType: enumeration + definition: + options: + 0: "High" + 1: "Medium" + 2: "Low *" + default: 2 + #param 2 + - name: "autoTurnOff" + title: "Auto-off Timer" + description: "Default: 0=disabled; Auto-off timer will automatically turn the Smart Plug off after x minutes once it has been turned on." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65535 + default: 0 + #param 4 + - name: "autoTurnOn" + title: "Auto-on Timer" + description: "Default: 0=disabled; Auto-on timer will automatically turn the Smart Plug on after x minutes once it has been turned off." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 65535 + default: 0 + #param 6 + - name: "powerRecovery" + title: "On/Off Status After Power Failure" + description: "Choose the recovery state for your Smart Plug if power outage occurs." + required: false + preferenceType: enumeration + definition: + options: + 2: "Restores Prior Status *" + 0: "Always Off once restored" + 1: "Always On once restored" + default: 2 + #param 8 + - name: "manualControl" + title: "Manual Control" + description: "Disable or enable manual control (turning the Smart Plug on and off using the physical button)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Enabled *" + default: 1 \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/init.lua b/drivers/SmartThings/zwave-switch/src/init.lua index 405600e962..7d622e586f 100644 --- a/drivers/SmartThings/zwave-switch/src/init.lua +++ b/drivers/SmartThings/zwave-switch/src/init.lua @@ -17,8 +17,6 @@ local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ ve local preferencesMap = require "preferences" local configurationsMap = require "configurations" -local lazy_load_if_possible = require "lazy_load_subdriver" - --- Map component to end_points(channels) --- --- @param device st.zwave.Device @@ -104,6 +102,7 @@ local driver_template = { capabilities.battery, capabilities.energyMeter, capabilities.powerMeter, + capabilities.voltageMeasurement, capabilities.colorControl, capabilities.button, capabilities.temperatureMeasurement, @@ -120,34 +119,14 @@ local driver_template = { [SwitchMultilevel.STOP_LEVEL_CHANGE] = switch_multilevel_stop_level_change_handler } }, - sub_drivers = { - lazy_load_if_possible("eaton-accessory-dimmer"), - lazy_load_if_possible("inovelli"), - lazy_load_if_possible("dawon-smart-plug"), - lazy_load_if_possible("inovelli-2-channel-smart-plug"), - lazy_load_if_possible("zwave-dual-switch"), - lazy_load_if_possible("eaton-anyplace-switch"), - lazy_load_if_possible("fibaro-wall-plug-us"), - lazy_load_if_possible("dawon-wall-smart-switch"), - lazy_load_if_possible("zooz-power-strip"), - lazy_load_if_possible("aeon-smart-strip"), - lazy_load_if_possible("qubino-switches"), - lazy_load_if_possible("fibaro-double-switch"), - lazy_load_if_possible("fibaro-single-switch"), - lazy_load_if_possible("eaton-5-scene-keypad"), - lazy_load_if_possible("ecolink-switch"), - lazy_load_if_possible("multi-metering-switch"), - lazy_load_if_possible("zooz-zen-30-dimmer-relay"), - lazy_load_if_possible("multichannel-device"), - lazy_load_if_possible("aeotec-smart-switch"), - lazy_load_if_possible("aeotec-heavy-duty") - }, + sub_drivers = require("sub_drivers"), lifecycle_handlers = { init = device_init, infoChanged = info_changed, doConfigure = do_configure, added = device_added - } + }, + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua index 7c0f6be77b..c8ba7ac681 100644 --- a/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua +++ b/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua @@ -3,6 +3,7 @@ local INOVELLI_FINGERPRINTS = { { mfr = 0x031E, prod = 0x0017, model = 0x0001 }, -- Inovelli VZW32-SN + { mfr = 0x031E, prod = 0x0015, model = 0x0001 }, -- Inovelli VZW31-SN { mfr = 0x031E, prod = 0x0001, model = 0x0001 }, -- Inovelli LZW31SN { mfr = 0x031E, prod = 0x0003, model = 0x0001 }, -- Inovelli LZW31 } @@ -17,4 +18,4 @@ local function can_handle_inovelli(opts, driver, device, ...) return false end -return can_handle_inovelli +return can_handle_inovelli \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua b/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua index e182120ece..2fdea81379 100644 --- a/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua +++ b/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua @@ -5,5 +5,6 @@ local lazy_load = require "lazy_load_subdriver" return { lazy_load("inovelli.lzw31-sn"), + lazy_load("inovelli.vzw31-sn"), lazy_load("inovelli.vzw32-sn") } diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua new file mode 100644 index 0000000000..2446c06dde --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_DIMMER_PRODUCT_ID = 0x0001 + +local function can_handle_vzw31_sn(opts, driver, device, ...) + if device:id_match( + INOVELLI_MANUFACTURER_ID, + INOVELLI_VZW31_SN_PRODUCT_TYPE, + INOVELLI_DIMMER_PRODUCT_ID + ) then + return true, require("inovelli.vzw31-sn") + end + return false +end + +return can_handle_vzw31_sn diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua new file mode 100644 index 0000000000..1dec88e745 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua @@ -0,0 +1,77 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.SwitchMultilevel +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +--- @type st.zwave.CommandClass.Association +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) +--- @type st.device +local st_device = require "st.device" +local cc = require "st.zwave.CommandClass" + + +local supported_button_values = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} + + +local function refresh_handler(driver, device) + device:send(SwitchMultilevel:Get({})) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.WATTS })) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS })) +end + +local function device_added(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(Association:Set({grouping_identifier = 1, node_ids = {driver.environment_info.hub_zwave_id}})) + for _, component in pairs(device.profile.components) do + if component.id ~= "main" and component.id ~= "LEDColorConfiguration" then + device:emit_component_event( + component, + capabilities.button.supportedButtonValues( + supported_button_values, + { visibility = { displayed = false } } + ) + ) + device:emit_component_event( + component, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + end + end + refresh_handler(driver, device) + else + device:emit_event(capabilities.colorControl.hue(1)) + device:emit_event(capabilities.colorControl.saturation(1)) + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + device:emit_event(capabilities.switchLevel.level(100)) + device:emit_event(capabilities.switch.switch("off")) + end +end + +local function onoff_level_report_handler(driver, device, cmd) + local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value + device:emit_event(value == 0 and capabilities.switch.switch.off() or capabilities.switch.switch.on()) + device:emit_event(capabilities.switchLevel.level(value)) +end + +local vzw31_sn = { + NAME = "Inovelli VZW31-SN Dimmer", + lifecycle_handlers = { + added = device_added, + }, + zwave_handlers = { + [cc.SWITCH_MULTILEVEL] = { + [SwitchMultilevel.REPORT] = onoff_level_report_handler + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + } + }, + can_handle = require("inovelli.vzw31-sn.can_handle") +} + +return vzw31_sn \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/preferences.lua b/drivers/SmartThings/zwave-switch/src/preferences.lua index 824e570070..09155cdd7d 100644 --- a/drivers/SmartThings/zwave-switch/src/preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/preferences.lua @@ -59,6 +59,33 @@ local devices = { switchType = {parameter_number = 22, size = 1} } }, + INOVELLI_VZW31_SN = { + MATCHING_MATRIX = { + mfrs = 0x031E, + product_types = {0x0015}, + product_ids = 0x0001 + }, + PARAMETERS = { + parameter158 = {parameter_number = 158, size = 1}, + parameter52 = {parameter_number = 52, size = 1}, + parameter1 = {parameter_number = 1, size = 1}, + parameter2 = {parameter_number = 2, size = 1}, + parameter3 = {parameter_number = 3, size = 1}, + parameter4 = {parameter_number = 4, size = 1}, + parameter9 = {parameter_number = 9, size = 1}, + parameter10 = {parameter_number = 10, size = 1}, + parameter15 = {parameter_number = 15, size = 1}, + parameter18 = {parameter_number = 18, size = 1}, + parameter19 = {parameter_number = 19, size = 2}, + parameter20 = {parameter_number = 20, size = 2}, + parameter22 = {parameter_number = 22, size = 1}, + parameter50 = {parameter_number = 50, size = 1}, + parameter95 = {parameter_number = 95, size = 1}, + parameter96 = {parameter_number = 96, size = 1}, + parameter97 = {parameter_number = 97, size = 1}, + parameter98 = {parameter_number = 98, size = 1}, + } + }, INOVELLI_VZW32_SN = { MATCHING_MATRIX = { mfrs = 0x031E, @@ -362,6 +389,21 @@ local devices = { dimmerPaddleControl = {parameter_number = 27, size = 1} } }, + ZOOZ_ZEN05 = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x7000, + product_ids = 0xB001 + }, + PARAMETERS = { + ledMode = { parameter_number = 1, size = 1 }, + autoTurnOff = { parameter_number = 2, size = 4 }, + autoTurnOn = { parameter_number = 4, size = 4 }, + powerRecovery = { parameter_number = 6, size = 1 }, + ledBrightness = { parameter_number = 7, size = 1 }, + manualControl = { parameter_number = 8, size = 1 }, + } + }, AEOTEC_HEAVY_DUTY = { MATCHING_MATRIX = { mfrs = 0x0086, diff --git a/drivers/SmartThings/zwave-switch/src/sub_drivers.lua b/drivers/SmartThings/zwave-switch/src/sub_drivers.lua new file mode 100644 index 0000000000..b4cd3d57e9 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/sub_drivers.lua @@ -0,0 +1,28 @@ + +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("eaton-accessory-dimmer"), + lazy_load_if_possible("inovelli"), + lazy_load_if_possible("dawon-smart-plug"), + lazy_load_if_possible("inovelli-2-channel-smart-plug"), + lazy_load_if_possible("zwave-dual-switch"), + lazy_load_if_possible("eaton-anyplace-switch"), + lazy_load_if_possible("fibaro-wall-plug-us"), + lazy_load_if_possible("dawon-wall-smart-switch"), + lazy_load_if_possible("zooz-power-strip"), + lazy_load_if_possible("aeon-smart-strip"), + lazy_load_if_possible("qubino-switches"), + lazy_load_if_possible("fibaro-double-switch"), + lazy_load_if_possible("fibaro-single-switch"), + lazy_load_if_possible("eaton-5-scene-keypad"), + lazy_load_if_possible("ecolink-switch"), + lazy_load_if_possible("multi-metering-switch"), + lazy_load_if_possible("zooz-zen-30-dimmer-relay"), + lazy_load_if_possible("multichannel-device"), + lazy_load_if_possible("aeotec-smart-switch"), + lazy_load_if_possible("aeotec-heavy-duty") +} diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeon_smart_strip.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeon_smart_strip.lua index f11a379666..86b5d5c539 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeon_smart_strip.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeon_smart_strip.lua @@ -150,6 +150,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {6}}) ) } + }, + { + min_api_version = 14 } ) @@ -236,6 +239,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {6}}) ) } + }, + { + min_api_version = 14 } ) @@ -284,6 +290,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {3}}) ) } + }, + { + min_api_version = 14 } ) @@ -332,6 +341,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {4}}) ) } + }, + { + min_api_version = 14 } ) @@ -363,6 +375,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("switch1", capabilities.energyMeter.energy({ value = 50.0, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -394,6 +409,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("switch2", capabilities.energyMeter.energy({ value = 50.0, unit = "kVAh" })) } + }, + { + min_api_version = 14 } ) @@ -425,6 +443,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("switch3", capabilities.powerMeter.power({ value = 50, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -492,6 +513,9 @@ test.register_message_test( {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {6}}) ) } + }, + { + min_api_version = 14 } ) @@ -523,6 +547,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("main", capabilities.powerMeter.power({ value = 50, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -592,7 +619,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 112, size = 4, configuration_value = 90}) )) mock_metering_switch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -723,7 +753,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -854,7 +887,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -942,7 +978,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1030,7 +1069,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1118,7 +1160,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1206,7 +1251,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dimmer_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dimmer_switch.lua index 6fb6c9c5ef..6829cd1c1b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dimmer_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dimmer_switch.lua @@ -58,7 +58,10 @@ test.register_coroutine_test( Configuration:Set({ parameter_number=111, size=4, configuration_value=300 }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -95,7 +98,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -133,6 +137,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 14 } ) @@ -170,6 +177,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 14 } ) @@ -211,6 +221,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 14 } ) @@ -265,6 +278,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 14 } ) @@ -294,6 +310,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -315,6 +334,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -381,7 +403,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -448,7 +473,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -480,7 +508,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dual_nano_switch_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dual_nano_switch_configuration.lua index 272321f53f..d951926231 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dual_nano_switch_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_dual_nano_switch_configuration.lua @@ -111,7 +111,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_heavy_duty_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_heavy_duty_switch.lua index a11fe3abcb..73228366f8 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_heavy_duty_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_heavy_duty_switch.lua @@ -55,6 +55,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -76,6 +79,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -116,7 +122,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -127,7 +134,10 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, Meter:Reset({}))) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -155,7 +165,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -178,7 +191,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -201,7 +217,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -225,7 +244,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -249,7 +271,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -273,7 +298,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -297,7 +325,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -321,7 +352,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -345,7 +379,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -369,7 +406,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -393,7 +433,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -417,7 +460,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -441,7 +487,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -465,7 +514,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_metering_switch_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_metering_switch_configuration.lua index 2ebeb664bb..eb022e95b5 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_metering_switch_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_metering_switch_configuration.lua @@ -63,7 +63,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer.lua index db1e4c2498..7c86c0c830 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer.lua @@ -82,7 +82,10 @@ test.register_coroutine_test( Configuration:Set({ parameter_number=113, size=4, configuration_value=300 }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -119,7 +122,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -157,6 +161,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 14 } ) @@ -194,6 +201,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 14 } ) @@ -235,6 +245,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 14 } ) @@ -289,6 +302,9 @@ test.register_message_test( Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) ) } + }, + { + min_api_version = 14 } ) @@ -318,6 +334,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -339,6 +358,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -405,7 +427,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -472,7 +497,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -504,7 +532,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer_preferences.lua index a8206a0052..2022dbf055 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_nano_dimmer_preferences.lua @@ -57,7 +57,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch.lua index fdba32d9cb..507aa2484f 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch.lua @@ -58,7 +58,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -85,7 +88,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_eu.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_eu.lua index 079616dba4..4cbfdea3a6 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_eu.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_eu.lua @@ -64,7 +64,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -91,7 +94,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -112,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -133,6 +142,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -161,7 +173,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) ) - end + end, + { + min_api_version = 14 + } ) do @@ -205,7 +220,11 @@ do SwitchColor:Get({ color_component_id=SwitchColor.color_component_id.RED }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_us.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_us.lua index 0ecb71e59d..47a222e06e 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_us.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_7_us.lua @@ -52,7 +52,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -79,7 +82,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -106,7 +112,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -127,6 +136,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -148,6 +160,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -176,7 +191,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.powerConsumptionReport.powerConsumption({ deltaEnergy = 0.0, energy = 5000 })) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_gen5.lua b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_gen5.lua index 4c2b76fdf6..4effe6c50a 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_gen5.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_aeotec_smart_switch_gen5.lua @@ -53,7 +53,10 @@ test.register_coroutine_test( Configuration:Set({ parameter_number = 103, configuration_value = 0, size = 4 }) )) mock_switch:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_dawon_smart_plug.lua b/drivers/SmartThings/zwave-switch/src/test/test_dawon_smart_plug.lua index cbfe5f42a0..5270baa847 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_dawon_smart_plug.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_dawon_smart_plug.lua @@ -47,6 +47,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -66,6 +69,9 @@ test.register_message_test( direction = "send", message = mock_metering_switch:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_dawon_wall_smart_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_dawon_wall_smart_switch.lua index ee48f99494..1fe37d24ca 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_dawon_wall_smart_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_dawon_wall_smart_switch.lua @@ -75,6 +75,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 22 })) } + }, + { + min_api_version = 14 } ) @@ -101,6 +104,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 25, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -130,6 +136,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch1", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -159,6 +168,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch1", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -188,6 +200,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch2", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -217,6 +232,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch2", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -246,6 +264,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch3", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -275,6 +296,9 @@ test.register_message_test( direction = "send", message = mock_multi_switch:generate_test_message("switch3", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -295,7 +319,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 1, size = 2, configuration_value = 10 * 60}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -357,7 +384,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_eaton_5_scene_keypad.lua b/drivers/SmartThings/zwave-switch/src/test/test_eaton_5_scene_keypad.lua index d50b7cdaa5..9cbfd0b38b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_eaton_5_scene_keypad.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_eaton_5_scene_keypad.lua @@ -74,7 +74,10 @@ test.register_coroutine_test( capabilities.switch.switch.off() ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -113,7 +116,10 @@ test.register_coroutine_test( capabilities.switch.switch.on() ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -139,7 +145,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -224,7 +233,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -259,7 +271,10 @@ test.register_coroutine_test( ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -316,7 +331,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -361,7 +379,10 @@ test.register_coroutine_test( } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -375,7 +396,10 @@ test.register_coroutine_test( ) } ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -432,7 +456,10 @@ test.register_coroutine_test( } ) -- if group_id and scene_id are same, do nothing. - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -454,7 +481,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -469,7 +499,10 @@ test.register_coroutine_test( } ) -- driver should do nothing. - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -486,7 +519,10 @@ test.register_coroutine_test( Indicator:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -503,7 +539,10 @@ test.register_coroutine_test( Indicator:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_eaton_accessory_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_eaton_accessory_dimmer.lua index 8b5e96b161..863f6b5d91 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_eaton_accessory_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_eaton_accessory_dimmer.lua @@ -42,6 +42,9 @@ test.register_message_test( direction = "receive", message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command(Basic:Report({ value = 0xFF })) } } + }, + { + min_api_version = 14 } ) @@ -63,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(0)) } + }, + { + min_api_version = 14 } ) @@ -87,6 +93,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -119,7 +128,11 @@ do direction = "send", message = mock_device:generate_test_message("main", capabilities.switchLevel.level(level)) } + }, + { + min_api_version = 14 } + ) end @@ -161,6 +174,9 @@ test.register_message_test( SwitchMultilevel:Get({}) ) } + }, + { + min_api_version = 14 } ) end @@ -185,7 +201,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -208,7 +227,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -233,7 +255,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_eaton_anyplace_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_eaton_anyplace_switch.lua index 6815a408da..79f6390aea 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_eaton_anyplace_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_eaton_anyplace_switch.lua @@ -71,6 +71,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -114,6 +117,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -134,7 +140,10 @@ test.register_coroutine_test( Basic:Report({value=0xFF}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -154,7 +163,10 @@ test.register_coroutine_test( Basic:Report({value=0x00}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -170,6 +182,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -186,6 +201,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_eaton_rf_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_eaton_rf_dimmer.lua index 99c6ae4068..5b1623758c 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_eaton_rf_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_eaton_rf_dimmer.lua @@ -41,7 +41,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number=7, configuration_value=1, size=1}) )) mock_sensor:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_ecolink_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_ecolink_switch.lua index 4f5557f9a3..e5ccfc170a 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_ecolink_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_ecolink_switch.lua @@ -62,7 +62,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -92,7 +93,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -109,6 +111,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -138,6 +143,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -167,6 +175,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -204,6 +215,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -233,6 +247,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -256,7 +273,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -280,7 +298,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_double_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_double_switch.lua index 59934e6439..81705116e3 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_double_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_double_switch.lua @@ -133,6 +133,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -182,6 +185,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) test.register_message_test( @@ -229,6 +235,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -277,6 +286,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -314,7 +326,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -351,7 +366,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -388,7 +406,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -425,7 +446,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -575,7 +599,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -624,7 +651,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_single_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_single_switch.lua index 0b40d6be33..3792254e03 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_single_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_single_switch.lua @@ -53,7 +53,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -71,7 +74,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -101,6 +107,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -131,6 +140,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -161,6 +173,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -191,6 +206,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -218,6 +236,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -244,6 +265,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -264,6 +288,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.held({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -290,6 +317,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -310,6 +340,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.down_hold({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -336,6 +369,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -356,6 +392,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.double({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -382,6 +421,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -402,6 +444,9 @@ test.register_message_test( message = mock_device:generate_test_message("main", capabilities.button.button.pushed_3x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -428,6 +473,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -447,6 +495,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -473,6 +524,9 @@ test.register_message_test( ) } }, + }, + { + min_api_version = 14 } ) @@ -492,6 +546,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 27, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -518,6 +575,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -629,7 +689,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_eu.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_eu.lua index 58ef03fc3f..f35d61d836 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_eu.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_eu.lua @@ -106,7 +106,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_uk_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_uk_configuration.lua index 44aae0f2fa..c6ab67534f 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_uk_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_uk_configuration.lua @@ -55,7 +55,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_us.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_us.lua index 8aa815e829..0a49231bc0 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_us.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_wall_plug_us.lua @@ -69,6 +69,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {1}}) ) }, + }, + { + min_api_version = 14 } ) @@ -103,6 +106,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}, {dst_channels = {1}}) ) }, + }, + { + min_api_version = 14 } ) @@ -134,6 +140,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.powerMeter.power({ value = 55, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -165,6 +174,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("smartplug1", capabilities.powerMeter.power({ value = 89, unit = "W" })) } + }, + { + min_api_version = 14 } ) @@ -196,6 +208,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -227,6 +242,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("smartplug1", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -238,7 +256,10 @@ test.register_coroutine_test( mock_device, SwitchBinary:Set({target_value=0xFF},{dst_channels={1}}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -317,7 +338,10 @@ test.register_coroutine_test( ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_dimmer_preferences.lua index db69dc9845..cae11364c1 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_dimmer_preferences.lua @@ -51,7 +51,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -71,7 +75,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -91,7 +99,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -111,7 +123,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -131,7 +147,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -152,7 +172,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -172,7 +196,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch.lua index f4e41b5bee..93d19204f4 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch.lua @@ -115,6 +115,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -168,6 +171,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -212,6 +218,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -243,6 +252,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -296,6 +308,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -349,6 +364,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -393,6 +411,9 @@ test.register_message_test( { device_uuid = mock_child.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -429,6 +450,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -496,7 +520,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -562,7 +589,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -626,7 +656,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -692,7 +725,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -733,7 +769,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -765,7 +804,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -797,7 +839,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch_preferences.lua index 2771cbc3a7..9001400001 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_fibaro_walli_double_switch_preferences.lua @@ -51,7 +51,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -68,7 +71,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -85,7 +91,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -102,7 +111,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -119,7 +131,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -136,7 +151,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_generic_zwave_device1.lua b/drivers/SmartThings/zwave-switch/src/test/test_generic_zwave_device1.lua index 309d2c79b0..a5865cd219 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_generic_zwave_device1.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_generic_zwave_device1.lua @@ -44,7 +44,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -69,7 +70,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -110,6 +112,9 @@ test.register_message_test( { device_uuid = mock_zwave_device1.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -137,6 +142,9 @@ test.register_message_test( { device_uuid = mock_zwave_device1.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -177,6 +185,9 @@ test.register_message_test( { device_uuid = mock_zwave_device1.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -203,7 +214,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -229,7 +243,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) local level = 49 @@ -256,7 +273,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_go_control_plug_in_switch_configuraton.lua b/drivers/SmartThings/zwave-switch/src/test/test_go_control_plug_in_switch_configuraton.lua index 9b72af072e..a83e2eddc1 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_go_control_plug_in_switch_configuraton.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_go_control_plug_in_switch_configuraton.lua @@ -47,7 +47,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_honeywell_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_honeywell_dimmer.lua index 4125fc6f00..ed0c1bfed1 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_honeywell_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_honeywell_dimmer.lua @@ -53,7 +53,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number=10, configuration_value=1, size=2}) )) mock_dimmer:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_2_channel_smart_plug.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_2_channel_smart_plug.lua index bc752ec6f1..32de5e2715 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_2_channel_smart_plug.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_2_channel_smart_plug.lua @@ -95,6 +95,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -156,6 +159,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -196,6 +202,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -236,6 +245,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -276,6 +288,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -316,6 +331,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -385,6 +403,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -455,6 +476,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -512,6 +536,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -569,6 +596,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -630,6 +660,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -691,6 +724,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -736,6 +772,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -781,6 +820,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -818,6 +860,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -856,6 +901,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -893,6 +941,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) @@ -931,6 +982,9 @@ test.register_message_test( }) ) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_button.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_button.lua index 0863f9eb43..8bf0b6f6f9 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_button.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_button.lua @@ -129,7 +129,10 @@ test.register_coroutine_test( ) ) end - end + end, + { + min_api_version = 14 + } ) @@ -154,6 +157,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -178,6 +184,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_4x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -202,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_inovelli_dimmer:generate_test_message("button3", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer.lua index d7dfe35b24..bb62be473b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer.lua @@ -46,7 +46,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -71,7 +72,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -99,6 +101,7 @@ test.register_message_test( } }, { + min_api_version = 14 } ) @@ -131,7 +134,11 @@ do direction = "send", message = mock_inovelli_dimmer:generate_test_message("main", capabilities.switchLevel.level(level)) } + }, + { + min_api_version = 14 } + ) end @@ -160,7 +167,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -188,7 +198,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) do @@ -218,7 +231,10 @@ do SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_led.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_led.lua index 6df8b97048..59588c0e52 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_led.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_led.lua @@ -97,7 +97,11 @@ do Configuration:Get({ parameter_number=LED_COLOR_CONTROL_PARAMETER_NUMBER }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -129,7 +133,11 @@ do direction = "send", message = mock_inovelli_dimmer:generate_test_message(LED_BAR_COMPONENT_NAME, capabilities.colorControl.saturation(LED_GENERIC_SATURATION)) } + }, + { + min_api_version = 14 } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_power_energy.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_power_energy.lua index eab58c99f9..dfdaf4ac54 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_power_energy.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_power_energy.lua @@ -69,7 +69,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -110,7 +111,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -132,7 +134,11 @@ do direction = "send", message = mock_inovelli_dimmer:generate_test_message("main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" })) } + }, + { + min_api_version = 14 } + ) end @@ -154,7 +160,11 @@ do direction = "send", message = mock_inovelli_dimmer:generate_test_message("main", capabilities.powerMeter.power({ value = power, unit = "W" })) } + }, + { + min_api_version = 14 } + ) end @@ -188,6 +198,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -228,7 +241,11 @@ do Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } + ) end @@ -257,7 +274,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -285,7 +305,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) do @@ -315,7 +338,11 @@ do SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_preferences.lua index 43dd38ee7f..a0c83ed6ae 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_preferences.lua @@ -54,7 +54,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -78,7 +82,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -102,7 +110,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -126,7 +138,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -151,7 +167,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_scenes.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_scenes.lua index 316b9b1254..aa89df6bf2 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_scenes.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_dimmer_scenes.lua @@ -57,6 +57,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -77,6 +80,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_2x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -97,6 +103,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_3x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -117,6 +126,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_4x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -137,6 +149,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button2", capabilities.button.button.pushed_5x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -157,6 +172,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -177,6 +195,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_2x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -197,6 +218,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_3x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -217,6 +241,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_4x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -237,6 +264,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button1", capabilities.button.button.pushed_5x({ state_change = true })) } + }, + { + min_api_version = 14 } ) @@ -257,6 +287,9 @@ test.register_message_test( message = mock_inovelli_dimmer:generate_test_message("button3", capabilities.button.button.pushed({ state_change = true })) } + }, + { + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua new file mode 100644 index 0000000000..a372a94eec --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua @@ -0,0 +1,287 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=2}) +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local CentralScene = (require "st.zwave.CommandClass.CentralScene")({version=3}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) +local Meter = (require "st.zwave.CommandClass.Meter")({version=3}) +local t_utils = require "integration_test.utils" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 +local LED_BAR_COMPONENT_NAME = "LEDColorConfiguration" + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + {value = zw.SWITCH_BINARY}, + {value = zw.SWITCH_MULTILEVEL}, + {value = zw.BASIC}, + {value = zw.CONFIGURATION}, + {value = zw.CENTRAL_SCENE}, + {value = zw.ASSOCIATION}, + {value = zw.METER}, + } + } +} + +-- Create mock device +local mock_inovelli_vzw31_sn = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzw31_sn) +end +test.set_test_init_function(test_init) + +local supported_button_values = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} + +-- Test device initialization +test.register_coroutine_test( + "Device should initialize properly on added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_inovelli_vzw31_sn.id, "added" }) + + for button_name, _ in pairs(mock_inovelli_vzw31_sn.profile.components) do + if button_name ~= "main" and button_name ~= LED_BAR_COMPONENT_NAME then + test.socket.capability:__expect_send( + mock_inovelli_vzw31_sn:generate_test_message( + button_name, + capabilities.button.supportedButtonValues( + supported_button_values, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzw31_sn:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Association:Set({ + grouping_identifier = 1, + node_ids = {}, -- Mock hub Z-Wave ID + payload = "\x01", -- Should contain grouping_identifier = 1 + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + ) + end +) + +-- Test switch on command +test.register_coroutine_test( + "Switch on command should send Basic Set with ON value", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switch", command = "on", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Basic:Set({ value = SwitchBinary.value.ON_ENABLE }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test switch off command +test.register_coroutine_test( + "Switch off command should send Basic Set with OFF value", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switch", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Basic:Set({ value = SwitchBinary.value.OFF_DISABLE }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test switch level command +test.register_coroutine_test( + "Switch level command should send SwitchMultilevel Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switchLevel", command = "setLevel", args = { 50 } } + }) + + local expected_command = SwitchMultilevel:Set({ value = 50, duration = "default" }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + expected_command + ) + ) + + test.wait_for_events() + test.mock_time.advance_time(3) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test central scene notifications +test.register_message_test( + "Central scene notification should emit button events", + { + { + channel = "zwave", + direction = "receive", + message = { mock_inovelli_vzw31_sn.id, zw_test_utils.zwave_test_build_receive_command(CentralScene:Notification({ + scene_number = 1, + key_attributes=CentralScene.key_attributes.KEY_PRESSED_1_TIME + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzw31_sn:generate_test_message("button1", capabilities.button.button.pushed({ + state_change = true + })) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test central scene notifications - button2 pressed 4 times +test.register_message_test( + "Central scene notification button2 pressed 4 times should emit button events", + { + { + channel = "zwave", + direction = "receive", + message = { mock_inovelli_vzw31_sn.id, zw_test_utils.zwave_test_build_receive_command(CentralScene:Notification({ + scene_number = 2, + key_attributes=CentralScene.key_attributes.KEY_PRESSED_4_TIMES + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzw31_sn:generate_test_message("button2", capabilities.button.button.pushed_4x({ + state_change = true + })) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test refresh capability +test.register_message_test( + "Refresh capability should request switch level and meter data", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzw31_sn.id, + { capability = "refresh", command = "refresh", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua new file mode 100644 index 0000000000..dea43715ab --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua @@ -0,0 +1,335 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=4}) +local t_utils = require "integration_test.utils" +local st_device = require "st.device" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + {value = zw.SWITCH_BINARY}, + {value = zw.SWITCH_MULTILEVEL}, + {value = zw.BASIC}, + {value = zw.CONFIGURATION}, + {value = zw.CENTRAL_SCENE}, + {value = zw.ASSOCIATION}, + } + } +} + +-- Create mock parent device +local mock_parent_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +-- Create mock child device (notification device) +local mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("rgbw-bulb.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "notification" +}) + +-- Set child device network type +mock_child_device.network_type = st_device.NETWORK_TYPE_CHILD + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) +end +test.set_test_init_function(test_init) + +-- Test child device initialization +test.register_message_test( + "Child device should initialize with default color values", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_child_device.id, "added" }, + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.hue(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switchLevel.level(100)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test child device switch on command (Gen3 uses parameter 99, same as vzw32) +test.register_coroutine_test( + "Child device switch on should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "on", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device switch off command +test.register_coroutine_test( + "Child device switch off should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "off", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = 0, -- Switch off sends 0 + size = 4 + }) + ) + ) + end +) + +-- Test child device level command +test.register_coroutine_test( + "Child device level command should emit events and send configuration to parent", + function() + local level = math.random(1, 99) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local effect = 1 -- Default notificationType + local color = 100 -- Default color for child devices (since device starts with no hue state) + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) -- Use the actual level from command + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switchLevel", command = "setLevel", args = { level } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switchLevel.level(level)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device color command +test.register_coroutine_test( + "Child device color command should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = math.random(0, 100) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorControl", command = "setColor", args = {{ hue = color, saturation = 100 }} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(color)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device color temperature command +test.register_coroutine_test( + "Child device color temperature command should emit events and send configuration to parent", + function() + local temp = math.random(2700, 6500) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorTemperature", command = "setColorTemperature", args = { temp } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(temp)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = 33514751, -- Calculated: effect(1)*16777216 + hue(255)*65536 + level(100)*256 + 255 + size = 4 + }) + ) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua new file mode 100644 index 0000000000..2b4812ad7b --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua @@ -0,0 +1,151 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local t_utils = require "integration_test.utils" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + { value = zw.SWITCH_BINARY }, + { value = zw.SWITCH_MULTILEVEL }, + { value = zw.BASIC }, + { value = zw.CONFIGURATION }, + { value = zw.CENTRAL_SCENE }, + { value = zw.ASSOCIATION }, + } + } +} + +-- Create mock device +local mock_inovelli_vzw31_sn = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzw31_sn) +end +test.set_test_init_function(test_init) + +-- Test parameter 1 (example preference) +do + local new_param_value = 10 + test.register_coroutine_test( + "Parameter 1 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter1 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 1, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 52 (example preference) +do + local new_param_value = 25 + test.register_coroutine_test( + "Parameter 52 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter52 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 52, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 158 (example preference) +do + local new_param_value = 5 + test.register_coroutine_test( + "Parameter 158 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter158 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 158, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 19 (2-byte parameter); must be non-default (default 3600) or driver won't send Configuration:Set +do + local new_param_value = 1800 + test.register_coroutine_test( + "Parameter 19 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter19 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 19, + configuration_value = new_param_value, + size = 2, + }) + ) + ) + end + ) +end + +-- Test notificationChild preference (special case for child device creation) +do + local new_param_value = true + test.register_coroutine_test( + "notificationChild preference should create child device when enabled", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {notificationChild = new_param_value}})) + + -- Expect child device creation + mock_inovelli_vzw31_sn:expect_device_create({ + type = "EDGE_CHILD", + label = "nil Notification", -- This will be the parent label + "Notification" + profile = "rgbw-bulb", + parent_device_id = mock_inovelli_vzw31_sn.id, + parent_assigned_child_key = "notification" + }) + end + ) +end + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn.lua index 5593ced043..cec88fe366 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn.lua @@ -128,7 +128,10 @@ test.register_coroutine_test( Notification:Get({notification_type = Notification.notification_type.HOME_SECURITY, event = Notification.event.home_security.MOTION_DETECTION}) ) ) - end + end, + { + min_api_version = 14 + } ) -- Test switch on command @@ -154,7 +157,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) -- Test switch off command @@ -180,7 +186,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) -- Test switch level command @@ -211,7 +220,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) -- Test central scene notifications @@ -235,7 +247,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -260,7 +273,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -318,7 +332,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_child.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_child.lua index 40213851c7..243bd08640 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_child.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_child.lua @@ -89,7 +89,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -142,7 +143,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) -- Test child device switch off command @@ -173,7 +177,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) -- Test child device level command @@ -229,7 +236,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) -- Test child device color command @@ -289,7 +299,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) -- Test child device color temperature command @@ -329,7 +342,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_preferences.lua index 8cccb5726e..be6db7e82b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw32_sn_preferences.lua @@ -58,7 +58,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -80,7 +84,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -102,7 +110,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -124,7 +136,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -144,8 +160,13 @@ do parent_device_id = mock_inovelli_vzw32_sn.id, parent_assigned_child_key = "notification" }) - end + end, + { + min_api_version = 14 + } + ) end -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() + diff --git a/drivers/SmartThings/zwave-switch/src/test/test_multi_metering_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_multi_metering_switch.lua index 11a79bc12d..3586b93aab 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_multi_metering_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_multi_metering_switch.lua @@ -114,7 +114,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -163,7 +166,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -212,7 +218,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -237,6 +246,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -262,6 +274,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -287,6 +302,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -312,6 +330,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -358,7 +379,10 @@ test.register_coroutine_test( )) mock_parent_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -398,6 +422,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -438,6 +465,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -465,7 +495,11 @@ do message = mock_parent_device:generate_test_message( "main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" })) } + }, + { + min_api_version = 14 } + ) end @@ -495,7 +529,11 @@ do "main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" }) ) } + }, + { + min_api_version = 14 } + ) end @@ -523,7 +561,11 @@ do message = mock_parent_device:generate_test_message( "main", capabilities.powerMeter.power({ value = power, unit = "W" })) } + }, + { + min_api_version = 14 } + ) end @@ -551,7 +593,11 @@ do message = mock_child_device:generate_test_message( "main", capabilities.powerMeter.power({ value = power, unit = "W" })) } + }, + { + min_api_version = 14 } + ) end @@ -582,7 +628,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -612,7 +661,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) do @@ -703,7 +755,11 @@ do ) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -716,7 +772,10 @@ test.register_coroutine_test( current_value = 0xFF }) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_multichannel_device.lua b/drivers/SmartThings/zwave-switch/src/test/test_multichannel_device.lua index 7f584e3125..c16703f52b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_multichannel_device.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_multichannel_device.lua @@ -167,6 +167,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -201,6 +204,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -235,6 +241,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -269,6 +278,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -316,6 +328,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -358,6 +373,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -400,6 +418,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -447,6 +468,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -494,6 +518,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -536,6 +563,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -578,6 +608,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -625,6 +658,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -665,6 +701,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -705,6 +744,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -745,6 +787,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -785,6 +830,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -838,6 +886,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -878,6 +929,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -931,6 +985,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -998,6 +1055,9 @@ test.register_message_test( SwitchMultilevel:Get({}, { dst_channels = { 5 } }) ) }, + }, + { + min_api_version = 14 } ) @@ -1038,7 +1098,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1079,7 +1140,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1120,7 +1182,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1164,6 +1227,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -1215,6 +1281,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -1241,6 +1310,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.contactSensor.contact.open()) } + }, + { + min_api_version = 14 } ) @@ -1267,6 +1339,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -1298,6 +1373,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.active()) } + }, + { + min_api_version = 14 } ) @@ -1329,6 +1407,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -1450,6 +1531,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.contactSensor.contact.closed()) } + }, + { + min_api_version = 14 } ) @@ -1571,6 +1655,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.motionSensor.motion.inactive()) } + }, + { + min_api_version = 14 } ) @@ -1697,6 +1784,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -1723,6 +1813,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -1749,6 +1842,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -1775,6 +1871,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.detected()) } + }, + { + min_api_version = 14 } ) @@ -1801,6 +1900,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.smokeDetector.smoke.clear()) } + }, + { + min_api_version = 14 } ) @@ -1827,6 +1929,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -1853,6 +1958,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -1879,6 +1987,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -1905,6 +2016,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -1931,6 +2045,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -1957,6 +2074,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -1983,6 +2103,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.wet()) } + }, + { + min_api_version = 14 } ) @@ -2009,6 +2132,9 @@ test.register_message_test( direction = "send", message = mock_child_4:generate_test_message("main", capabilities.waterSensor.water.dry()) } + }, + { + min_api_version = 14 } ) @@ -2044,6 +2170,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -2070,6 +2199,9 @@ test.register_message_test( direction = "send", message = mock_child_5:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 70 })) } + }, + { + min_api_version = 14 } ) @@ -2097,6 +2229,9 @@ test.register_message_test( direction = "send", message = mock_child_5:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance({ value = 400, unit = "lux" })) } + }, + { + min_api_version = 14 } ) @@ -2145,7 +2280,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -2200,7 +2336,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -2327,7 +2464,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -2370,7 +2508,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -2390,6 +2529,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -2406,7 +2548,10 @@ test.register_coroutine_test( base_parent:expect_device_create( prepare_metadata(base_parent, 1, "metering-switch") ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -2425,6 +2570,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -2441,7 +2589,10 @@ test.register_coroutine_test( base_parent:expect_device_create( prepare_metadata(base_parent, 3, "metering-dimmer") ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -2460,6 +2611,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -2476,7 +2630,10 @@ test.register_coroutine_test( base_parent:expect_device_create( prepare_metadata(base_parent, 4, "generic-sensor") ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -2495,6 +2652,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -2511,7 +2671,10 @@ test.register_coroutine_test( base_parent:expect_device_create( prepare_metadata(base_parent, 5, "generic-multi-sensor") ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_popp_outdoor_plug_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_popp_outdoor_plug_configuration.lua index 165dab059e..b972f5c936 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_popp_outdoor_plug_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_popp_outdoor_plug_configuration.lua @@ -47,7 +47,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer.lua index 9cec4ecd4e..8688cd7ec4 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer.lua @@ -68,7 +68,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -121,7 +124,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -159,6 +163,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -197,6 +204,9 @@ test.register_message_test( {scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -238,6 +248,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -292,6 +305,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -321,6 +337,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -342,6 +361,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -358,7 +380,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -424,7 +449,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -491,7 +519,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -522,7 +553,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer_preferences.lua index 920c6c5b2f..86e3f8460b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_din_dimmer_preferences.lua @@ -54,7 +54,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -74,7 +78,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -94,7 +102,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -114,7 +126,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -134,7 +150,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -155,7 +175,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -175,7 +199,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1_relay_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1_relay_preferences.lua index d44eb8f7cb..2c1d6f337b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1_relay_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1_relay_preferences.lua @@ -46,7 +46,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -66,7 +70,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -86,7 +94,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -106,7 +118,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -126,7 +142,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1d_relay_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1d_relay_preferences.lua index 3436ccba26..f67ac02a67 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1d_relay_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_1d_relay_preferences.lua @@ -46,7 +46,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -66,7 +70,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -86,7 +94,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay.lua index 61a62ef0d7..646370e760 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay.lua @@ -104,7 +104,10 @@ test.register_coroutine_test( { dst_channels = { 1 } } ) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -133,7 +136,10 @@ test.register_coroutine_test( { dst_channels = { 2 } } ) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -151,7 +157,10 @@ test.register_coroutine_test( { encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = { 3 } } ) )) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -177,7 +186,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -204,7 +214,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -228,7 +239,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -252,7 +264,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -277,6 +290,9 @@ test.register_message_test( direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -301,6 +317,9 @@ test.register_message_test( direction = "send", message = mock_child_2_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -333,6 +352,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -365,6 +387,9 @@ test.register_message_test( { device_uuid = mock_parent_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -391,6 +416,9 @@ test.register_message_test( "main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' }) ) } + }, + { + min_api_version = 14 } ) @@ -430,7 +458,10 @@ test.register_coroutine_test( mock_parent_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }, { dst_channels = { 1 } }) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -469,7 +500,10 @@ test.register_coroutine_test( mock_parent_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }, { dst_channels = { 1 } }) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -508,7 +542,10 @@ test.register_coroutine_test( mock_parent_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }, { dst_channels = { 2 } }) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -547,7 +584,10 @@ test.register_coroutine_test( mock_parent_device, Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }, { dst_channels = { 2 } }) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -597,7 +637,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay_preferences.lua index 3ac1fda4b4..e050946859 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_2_relay_preferences.lua @@ -46,7 +46,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -66,7 +70,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -86,7 +94,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -106,7 +118,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -126,7 +142,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer.lua index cfe8dccde7..7352331b05 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer.lua @@ -59,7 +59,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -112,7 +115,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -150,6 +154,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -188,6 +195,9 @@ test.register_message_test( {scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -229,6 +239,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -283,6 +296,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -312,6 +328,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -333,6 +352,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -347,7 +369,10 @@ test.register_coroutine_test( } ) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' }))) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -413,7 +438,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -480,7 +508,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -511,7 +542,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_0_10V_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_0_10V_preferences.lua index db28991f2d..7dad04cd8c 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_0_10V_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_0_10V_preferences.lua @@ -50,7 +50,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -70,7 +74,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -90,7 +98,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -110,7 +122,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -130,7 +146,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -154,7 +174,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -178,7 +202,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_preferences.lua index d88d6c39b1..95c994a637 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_flush_dimmer_preferences.lua @@ -49,7 +49,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -69,7 +73,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -89,7 +97,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -109,7 +121,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -129,7 +145,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -149,7 +169,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -169,7 +193,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -190,7 +218,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -210,7 +242,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_mini_dimmer_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_mini_dimmer_preferences.lua index f385e0be33..d510e9cfed 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_mini_dimmer_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_mini_dimmer_preferences.lua @@ -49,7 +49,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -69,7 +73,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -89,7 +97,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -109,7 +121,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -129,7 +145,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -150,7 +170,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -170,7 +194,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -190,7 +218,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_with_power.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_with_power.lua index ccaf9c7aec..0606989b4d 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_with_power.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_with_power.lua @@ -57,7 +57,10 @@ test.register_coroutine_test( ) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -94,6 +97,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -132,6 +138,9 @@ test.register_message_test( {scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -161,6 +170,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -182,6 +194,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -204,6 +219,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -270,7 +288,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -337,7 +358,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -378,7 +402,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_without_power.lua b/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_without_power.lua index c06f516249..7a2c7ea56b 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_without_power.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_qubino_temperature_sensor_without_power.lua @@ -58,6 +58,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -87,6 +90,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -109,6 +115,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -159,7 +168,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.switch.switch.on()) ) mock_device:expect_native_attr_handler_registration("switch", "switch") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -210,7 +222,10 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.switch.switch.off()) ) mock_device:expect_native_attr_handler_registration("switch", "switch") - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -251,7 +266,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_shelly_multi_metering_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_shelly_multi_metering_switch.lua index 331640b1be..28165e645c 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_shelly_multi_metering_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_shelly_multi_metering_switch.lua @@ -113,7 +113,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -162,7 +165,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -211,7 +217,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -236,6 +245,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -261,6 +273,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -286,6 +301,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -311,6 +329,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -351,6 +372,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -391,6 +415,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -418,7 +445,11 @@ do message = mock_parent_device:generate_test_message( "main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" })) } + }, + { + min_api_version = 14 } + ) end @@ -448,7 +479,11 @@ do "main", capabilities.energyMeter.energy({ value = energy, unit = "kWh" }) ) } + }, + { + min_api_version = 14 } + ) end @@ -476,7 +511,11 @@ do message = mock_parent_device:generate_test_message( "main", capabilities.powerMeter.power({ value = power, unit = "W" })) } + }, + { + min_api_version = 14 } + ) end @@ -504,7 +543,11 @@ do message = mock_child_device:generate_test_message( "main", capabilities.powerMeter.power({ value = power, unit = "W" })) } + }, + { + min_api_version = 14 } + ) end @@ -535,7 +578,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -565,7 +611,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) do @@ -656,7 +705,11 @@ do ) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -669,7 +722,10 @@ test.register_coroutine_test( current_value = 0xFF }) }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch.lua b/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch.lua index 9bc1387f2b..a09be9a417 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch.lua @@ -69,7 +69,10 @@ test.register_coroutine_test( Configuration:Set({parameter_number = 2, size = 1, configuration_value = 1}) )) mock_parent_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -94,6 +97,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -119,6 +125,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -144,6 +153,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -169,6 +181,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -199,6 +214,9 @@ test.register_message_test( direction = "send", message = mock_parent_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -229,6 +247,9 @@ test.register_message_test( direction = "send", message = mock_child_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -260,7 +281,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -290,7 +314,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) local function prepare_metadata(device, endpoint, profile) @@ -321,7 +348,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -337,7 +367,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch_configuration.lua b/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch_configuration.lua index 010607fc2d..fcd724acab 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch_configuration.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_wyfy_touch_configuration.lua @@ -46,7 +46,10 @@ test.register_coroutine_test( }) )) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zooz_double_plug.lua b/drivers/SmartThings/zwave-switch/src/test/test_zooz_double_plug.lua index abf41946cc..7725238086 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zooz_double_plug.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zooz_double_plug.lua @@ -71,7 +71,10 @@ test.register_coroutine_test( }) )) mock_parent:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -119,7 +122,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -166,7 +172,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -190,6 +199,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -215,6 +227,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -239,6 +254,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -264,6 +282,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -303,6 +324,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -342,6 +366,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -381,6 +408,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -420,6 +450,9 @@ test.register_message_test( ) ) } + }, + { + min_api_version = 14 } ) @@ -441,6 +474,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -462,6 +498,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -491,6 +530,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -520,6 +562,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zooz_power_strip.lua b/drivers/SmartThings/zwave-switch/src/test/test_zooz_power_strip.lua index 477886d76e..15ef4f227d 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zooz_power_strip.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zooz_power_strip.lua @@ -83,6 +83,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -119,6 +122,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -155,6 +161,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -191,6 +200,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -227,6 +239,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -263,6 +278,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -299,6 +317,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -336,6 +357,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -373,6 +397,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -410,6 +437,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -451,6 +481,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -491,6 +524,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -531,6 +567,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -571,6 +610,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -611,6 +653,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -651,6 +696,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -691,6 +739,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -731,6 +782,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -771,6 +825,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -811,6 +868,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -870,7 +930,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_device:generate_test_message("switch1", capabilities.switch.switch.off())) test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.off())) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -947,7 +1010,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1025,7 +1089,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1060,7 +1125,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1095,7 +1161,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1130,7 +1197,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1165,7 +1233,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1200,7 +1269,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1235,7 +1305,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1270,7 +1341,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1305,7 +1377,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1340,7 +1413,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -1375,7 +1449,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay.lua b/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay.lua index b3a18291e7..40105722ac 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay.lua @@ -92,7 +92,10 @@ test.register_coroutine_test( Version:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -109,7 +112,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, { dst_channels = { 1 } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -138,6 +144,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -167,6 +176,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -204,6 +216,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -246,6 +261,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -292,6 +310,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -325,6 +346,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -354,6 +378,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -383,6 +410,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -411,7 +441,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}, { dst_channels = { 0 } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -439,7 +472,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}, { dst_channels = { 0 } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -467,7 +503,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, { dst_channels = { 1 } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -495,7 +534,10 @@ test.register_coroutine_test( SwitchBinary:Get({}, { dst_channels = { 1 } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -528,7 +570,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}, { dst_channels = { 0 } }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -574,6 +619,9 @@ test.register_message_test( { device_uuid = mock_parent.id, capability_id = "switchLevel", capability_attr_id = "level" } } }, + }, + { + min_api_version = 14 } ) @@ -598,6 +646,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -622,6 +673,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -646,6 +700,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -670,6 +727,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_2x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -694,6 +754,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_2x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -718,6 +781,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed_2x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -742,6 +808,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_3x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -766,6 +835,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_3x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -790,6 +862,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed_3x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -814,6 +889,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_4x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -838,6 +916,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_4x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -862,6 +943,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed_4x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -886,6 +970,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_5x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -910,6 +997,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_5x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -934,6 +1024,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.pushed_5x({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -958,6 +1051,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.up_hold({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -982,6 +1078,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.down_hold({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -1006,6 +1105,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.button.button.held({state_change = true})) } + }, + { + min_api_version = 14 } ) @@ -1025,6 +1127,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -1050,7 +1155,10 @@ test.register_coroutine_test( } ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1075,7 +1183,10 @@ test.register_coroutine_test( } ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1111,7 +1222,10 @@ test.register_coroutine_test( }) }) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1156,7 +1270,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -1172,7 +1289,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay_preferences.lua index c186f9d36b..ab3923bb2a 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay_preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zooz_zen_30_dimmer_relay_preferences.lua @@ -77,7 +77,11 @@ do }) ) test.wait_for_events() - end + end, + { + min_api_version = 14 + } + ) end diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dimmer_power_energy.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dimmer_power_energy.lua index 215cc0675d..efb6bd1ee2 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dimmer_power_energy.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dimmer_power_energy.lua @@ -70,6 +70,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -108,6 +111,9 @@ test.register_message_test( {scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -149,6 +155,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -203,6 +212,9 @@ test.register_message_test( Meter:Get({scale = Meter.scale.electric_meter.WATTS}) ) } + }, + { + min_api_version = 14 } ) @@ -232,6 +244,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -253,6 +268,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -319,7 +337,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -385,7 +406,10 @@ test.register_coroutine_test( ) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -415,7 +439,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch.lua index 84502e9835..989bc0f054 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_dual_switch.lua @@ -80,6 +80,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -111,6 +114,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -142,6 +148,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -173,6 +182,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -204,6 +216,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -235,6 +250,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -266,6 +284,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -297,6 +318,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -334,6 +358,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -371,6 +398,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -408,6 +438,9 @@ test.register_message_test( direction = "send", message = mock_parent:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -445,6 +478,9 @@ test.register_message_test( direction = "send", message = mock_child:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -482,7 +518,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -519,7 +558,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -556,7 +598,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -593,7 +638,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -614,7 +662,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -635,7 +686,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() 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 ac396439f6..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 @@ -72,7 +76,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -96,7 +103,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch.lua index effb5845b4..7c8da1f4ef 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch.lua @@ -76,6 +76,9 @@ test.register_message_test( { device_uuid = mock_switch_binary.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -100,6 +103,9 @@ test.register_message_test( { device_uuid = mock_switch_binary.id, capability_id = "switch", capability_attr_id = "switch" } } }, + }, + { + min_api_version = 14 } ) @@ -122,7 +128,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -147,7 +154,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -169,7 +177,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -194,7 +203,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -226,7 +236,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -257,7 +270,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -288,7 +304,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -319,7 +338,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_battery.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_battery.lua index 26a72eb4e6..ad20f7a864 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_battery.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_battery.lua @@ -43,6 +43,9 @@ test.register_message_test( direction = "send", message = mock_switch:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -72,7 +75,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -105,7 +109,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_electric_meter.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_electric_meter.lua index 935e4b63e8..e008f82e94 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_electric_meter.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_electric_meter.lua @@ -55,6 +55,9 @@ test.register_message_test( { device_uuid = mock_switch.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -74,6 +77,9 @@ test.register_message_test( direction = "send", message = mock_switch:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -111,7 +117,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -152,7 +159,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_energy_meter.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_energy_meter.lua index 016cb56ef1..ad3597766c 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_energy_meter.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_energy_meter.lua @@ -61,6 +61,9 @@ test.register_message_test( { device_uuid = mock_switch.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -80,6 +83,9 @@ test.register_message_test( direction = "send", message = mock_switch:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -109,7 +115,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -142,7 +149,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -152,7 +160,10 @@ test.register_coroutine_test( test.socket.capability:__queue_receive({mock_switch.id, { capability = "energyMeter", component = "main", command = "resetEnergyMeter", args = {}}}) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( mock_switch, Meter:Reset({}))) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command( mock_switch, Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}))) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_level.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_level.lua index c8cca92dfb..53b09297a0 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_level.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_level.lua @@ -45,7 +45,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -70,7 +71,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_power_meter.lua b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_power_meter.lua index edec7aefba..fef5989b44 100644 --- a/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_power_meter.lua +++ b/drivers/SmartThings/zwave-switch/src/test/test_zwave_switch_power_meter.lua @@ -66,6 +66,9 @@ test.register_message_test( { device_uuid = mock_switch.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -80,6 +83,9 @@ test.register_message_test( meter_value = 5}) )} }, + }, + { + min_api_version = 14 } ) @@ -109,7 +115,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -142,7 +149,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) diff --git a/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/can_handle.lua b/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/can_handle.lua deleted file mode 100644 index 079eeee5d3..0000000000 --- a/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/can_handle.lua +++ /dev/null @@ -1,25 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local function can_handle(opts, driver, device, cmd, ...) - local version = require "version" - local cc = require "st.zwave.CommandClass" - local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - local DANFOSS_LC13_THERMOSTAT_FPS = require "apiv6_bugfix.fingerprints" - - if version.api == 6 and - cmd.cmd_class == cc.WAKE_UP and - cmd.cmd_id == WakeUp.NOTIFICATION and not - (device:id_match(DANFOSS_LC13_THERMOSTAT_FPS[1].manufacturerId, - DANFOSS_LC13_THERMOSTAT_FPS[1].productType, - DANFOSS_LC13_THERMOSTAT_FPS[1].productId) or - device:id_match(DANFOSS_LC13_THERMOSTAT_FPS[2].manufacturerId, - DANFOSS_LC13_THERMOSTAT_FPS[2].productType, - DANFOSS_LC13_THERMOSTAT_FPS[2].productId)) then - return true, require "apiv6_bugfix" - else - return false - end -end - -return can_handle diff --git a/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/fingerprints.lua b/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/fingerprints.lua deleted file mode 100644 index e87a5990e2..0000000000 --- a/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/fingerprints.lua +++ /dev/null @@ -1,9 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local DANFOSS_LC13_THERMOSTAT_FPS = { - { manufacturerId = 0x0002, productType = 0x0005, productId = 0x0003 }, -- Danfoss LC13 Thermostat - { manufacturerId = 0x0002, productType = 0x0005, productId = 0x0004 } -- Danfoss LC13 Thermostat -} - -return DANFOSS_LC13_THERMOSTAT_FPS diff --git a/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/init.lua b/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/init.lua deleted file mode 100644 index 52c419a590..0000000000 --- a/drivers/SmartThings/zwave-thermostat/src/apiv6_bugfix/init.lua +++ /dev/null @@ -1,22 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local cc = require "st.zwave.CommandClass" -local WakeUp = (require "st.zwave.CommandClass.WakeUp")({ version = 1 }) - - -local function wakeup_notification(driver, device, cmd) - device:refresh() -end - -local apiv6_bugfix = { - zwave_handlers = { - [cc.WAKE_UP] = { - [WakeUp.NOTIFICATION] = wakeup_notification - } - }, - NAME = "apiv6_bugfix", - can_handle = require("apiv6_bugfix.can_handle"), -} - -return apiv6_bugfix diff --git a/drivers/SmartThings/zwave-thermostat/src/init.lua b/drivers/SmartThings/zwave-thermostat/src/init.lua index 3668085b1a..6a79c1f4e1 100755 --- a/drivers/SmartThings/zwave-thermostat/src/init.lua +++ b/drivers/SmartThings/zwave-thermostat/src/init.lua @@ -106,6 +106,7 @@ local driver_template = { added = device_added }, sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities, {native_capability_attrs_enabled = true}) diff --git a/drivers/SmartThings/zwave-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zwave-thermostat/src/sub_drivers.lua index dc30091b79..38b6d5de87 100644 --- a/drivers/SmartThings/zwave-thermostat/src/sub_drivers.lua +++ b/drivers/SmartThings/zwave-thermostat/src/sub_drivers.lua @@ -10,6 +10,5 @@ local sub_drivers = { lazy_load_if_possible("stelpro-ki-thermostat"), lazy_load_if_possible("qubino-flush-thermostat"), lazy_load_if_possible("thermostat-heating-battery"), - lazy_load_if_possible("apiv6_bugfix"), } return sub_drivers diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua old mode 100755 new mode 100644 index 69034f9b5a..9bb950ffca --- a/drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_aeotec_radiator_thermostat.lua @@ -94,7 +94,8 @@ test.register_message_test( -- }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -111,6 +112,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -127,6 +131,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -155,6 +162,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -172,6 +182,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "heat" })) } + }, + { + min_api_version = 14 } ) @@ -192,6 +205,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -217,7 +233,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -247,7 +266,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_ct100_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_ct100_thermostat.lua index 55a35dd2a9..dce072706c 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_ct100_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_ct100_thermostat.lua @@ -136,7 +136,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -162,7 +163,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 68, unit = "C"}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -187,7 +191,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({value = 68, unit = "C"}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -212,7 +219,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({value = 45}) )) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -268,6 +278,9 @@ test.register_message_test( ThermostatSetpoint:Get({setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1}) ) } + }, + { + min_api_version = 14 } ) @@ -324,6 +337,9 @@ test.register_message_test( ThermostatSetpoint:Get({setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1}) ) } + }, + { + min_api_version = 14 } ) @@ -386,7 +402,10 @@ test.register_coroutine_test( ThermostatOperatingState:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -452,7 +471,10 @@ test.register_coroutine_test( ThermostatOperatingState:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) -- these next two tests are based on actual messages from a real device @@ -475,7 +497,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 60.0, unit = 'F'}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -497,7 +522,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({value = 48}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_fibaro_heat_controller.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_fibaro_heat_controller.lua index cac8b883e4..8f5f9011e1 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_fibaro_heat_controller.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_fibaro_heat_controller.lua @@ -115,7 +115,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -172,7 +173,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -189,6 +191,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -209,6 +214,9 @@ test.register_message_test( direction = "send", message = mock_device_extended:generate_test_message("extraTemperatureSensor", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -225,6 +233,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -253,6 +264,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -270,6 +284,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "heat" })) } + }, + { + min_api_version = 14 } ) @@ -290,6 +307,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -333,7 +353,10 @@ test.register_coroutine_test( Configuration:Get({parameter_number = 3}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -358,7 +381,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -388,7 +414,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua old mode 100755 new mode 100644 index 3671cd4029..2c74e4c5be --- a/drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_popp_radiator_thermostat.lua @@ -60,6 +60,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -76,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -104,6 +110,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -124,6 +133,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -145,7 +157,10 @@ test.register_coroutine_test( ) test.mock_time.advance_time(200) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -186,7 +201,10 @@ test.register_coroutine_test( mock_device, ThermostatSetpoint:Get({setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1})) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_qubino_flush_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_qubino_flush_thermostat.lua index 2c9b04d82d..a1842ae574 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_qubino_flush_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_qubino_flush_thermostat.lua @@ -122,7 +122,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -144,7 +145,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -168,6 +170,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -186,6 +191,9 @@ test.register_message_test( ) } } + }, + { + min_api_version = 14 } ) @@ -203,6 +211,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "heat" })) } + }, + { + min_api_version = 14 } ) @@ -227,6 +238,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -251,6 +265,9 @@ test.register_message_test( direction = "send", message = mock_device_cooling:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -305,7 +322,10 @@ test.register_coroutine_test( Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -354,7 +374,10 @@ test.register_coroutine_test( Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -379,7 +402,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -409,7 +435,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -434,6 +463,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) } + }, + { + min_api_version = 14 } ) @@ -453,6 +485,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.energyMeter.energy({ value = 5, unit = "kWh" })) } + }, + { + min_api_version = 14 } ) @@ -480,6 +515,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "powerMeter", capability_attr_id = "power" } } } + }, + { + min_api_version = 15 } ) @@ -530,7 +568,10 @@ test.register_coroutine_test( Configuration:Get({ parameter_number = 59 }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_stelpro_ki_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_stelpro_ki_thermostat.lua index 2c9c370bec..bb4043f969 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_stelpro_ki_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_stelpro_ki_thermostat.lua @@ -66,7 +66,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -102,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 33, unit = 'C'})) } + }, + { + min_api_version = 14 } ) @@ -138,6 +144,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 55, unit = 'F'})) } + }, + { + min_api_version = 14 } ) @@ -179,6 +188,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 30, unit = 'F'})) } + }, + { + min_api_version = 14 } ) @@ -220,6 +232,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({value = 122, unit = 'F'})) } + }, + { + min_api_version = 14 } ) @@ -256,6 +271,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.freeze()) } + }, + { + min_api_version = 14 } ) @@ -292,6 +310,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureAlarm.temperatureAlarm.heat()) } + }, + { + min_api_version = 14 } ) @@ -307,6 +328,9 @@ test.register_message_test( energy_save_heat = true })) } } + }, + { + min_api_version = 14 } ) @@ -325,6 +349,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "eco" })) } + }, + { + min_api_version = 14 } ) @@ -348,7 +375,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_thermostat_heating_battery.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_thermostat_heating_battery.lua index 9680419946..70ec5c90d2 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_thermostat_heating_battery.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_thermostat_heating_battery.lua @@ -102,7 +102,10 @@ test.register_coroutine_test( "doConfigure() should generate WakeUp:IntervalSet", function() do_initial_setup() - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -118,6 +121,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -147,7 +153,10 @@ test.register_coroutine_test( mock_device, WakeUp:IntervalGet({}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -206,7 +215,10 @@ test.register_coroutine_test( zw_test_utilities.zwave_test_build_send_command(mock_device, Clock:Set({hour=now.hour, minute=now.min, weekday=WEEK[now.wday]})) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -225,6 +237,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 25, unit = "C"})) }, + }, + { + min_api_version = 14 } ) @@ -254,6 +269,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 4, unit = "C"})) }, + }, + { + min_api_version = 14 } ) @@ -283,6 +301,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 28, unit = "C"})) }, + }, + { + min_api_version = 14 } ) @@ -309,7 +330,10 @@ test.register_coroutine_test( capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 50, unit = "F"}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -399,7 +423,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -510,7 +537,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -610,7 +640,10 @@ test.register_coroutine_test( {mock_device.id, zw_test_utilities.zwave_test_build_receive_command(WakeUp:Notification({}))} ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -693,7 +726,10 @@ test.register_coroutine_test( }) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -732,7 +768,10 @@ test.register_coroutine_test( mock_device, WakeUp:IntervalGet({}) )) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -769,7 +808,10 @@ test.register_coroutine_test( WakeUp:IntervalGet({}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-thermostat/src/test/test_zwave_thermostat.lua b/drivers/SmartThings/zwave-thermostat/src/test/test_zwave_thermostat.lua index ea14555a0a..f4d0a6c811 100644 --- a/drivers/SmartThings/zwave-thermostat/src/test/test_zwave_thermostat.lua +++ b/drivers/SmartThings/zwave-thermostat/src/test/test_zwave_thermostat.lua @@ -143,7 +143,8 @@ test.register_message_test( table.unpack(refresh_commands) }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -161,7 +162,8 @@ test.register_message_test( table.unpack(refresh_commands) }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -178,6 +180,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(99)) } + }, + { + min_api_version = 14 } ) @@ -194,6 +199,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.battery.battery(1)) } + }, + { + min_api_version = 14 } ) @@ -216,6 +224,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes({ "off", "heat", "cool", "auto" }, {visibility={displayed=false}})) } + }, + { + min_api_version = 14 } ) @@ -237,6 +248,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.supportedThermostatFanModes({ "on", "auto", "circulate" }, {visibility={displayed=false}})) } + }, + { + min_api_version = 14 } ) @@ -265,6 +279,9 @@ test.register_message_test( { device_uuid = mock_device.id, capability_id = "temperatureMeasurement", capability_attr_id = "temperature" } } } + }, + { + min_api_version = 15 } ) @@ -285,6 +302,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.relativeHumidityMeasurement.humidity({ value = 22 })) } + }, + { + min_api_version = 14 } ) @@ -302,6 +322,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode({ value = "heat" })) } + }, + { + min_api_version = 14 } ) @@ -319,6 +342,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatFanMode.thermostatFanMode({ value = "circulate" })) } + }, + { + min_api_version = 14 } ) @@ -339,6 +365,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = 'C' })) } + }, + { + min_api_version = 14 } ) @@ -359,6 +388,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 68, unit = 'F' })) } + }, + { + min_api_version = 14 } ) @@ -384,6 +416,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) } + }, + { + min_api_version = 14 } ) @@ -409,7 +444,10 @@ test.register_coroutine_test( ThermostatFanMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -434,7 +472,10 @@ test.register_coroutine_test( ThermostatFanMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -459,7 +500,10 @@ test.register_coroutine_test( ThermostatFanMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -484,7 +528,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -509,7 +556,10 @@ test.register_coroutine_test( ThermostatMode:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -539,7 +589,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -590,7 +643,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -640,7 +696,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -692,7 +751,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-valve/src/init.lua b/drivers/SmartThings/zwave-valve/src/init.lua index 43de12dcc4..f430c19a4a 100644 --- a/drivers/SmartThings/zwave-valve/src/init.lua +++ b/drivers/SmartThings/zwave-valve/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -26,10 +16,8 @@ local driver_template = { supported_capabilities = { capabilities.valve, }, - sub_drivers = { - -- Fortrezz and Zooz valves treat open as "off" and close as "on" - require("inverse_valve") - }, + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-valve/src/inverse_valve/can_handle.lua b/drivers/SmartThings/zwave-valve/src/inverse_valve/can_handle.lua new file mode 100644 index 0000000000..b36334f2c9 --- /dev/null +++ b/drivers/SmartThings/zwave-valve/src/inverse_valve/can_handle.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function inverse_valve_can_handle(opts, driver, device, ...) + if device.zwave_manufacturer_id == 0x0084 or device.zwave_manufacturer_id == 0x027A then + return true, require("inverse_valve") + end + return false +end + +return inverse_valve_can_handle diff --git a/drivers/SmartThings/zwave-valve/src/inverse_valve/init.lua b/drivers/SmartThings/zwave-valve/src/inverse_valve/init.lua index 510e79019b..853bada351 100644 --- a/drivers/SmartThings/zwave-valve/src/inverse_valve/init.lua +++ b/drivers/SmartThings/zwave-valve/src/inverse_valve/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -55,9 +45,7 @@ local inverse_valve = { [capabilities.valve.commands.close.NAME] = close_handler } }, - can_handle = function(opts, driver, device, ...) - return device.zwave_manufacturer_id == 0x0084 or device.zwave_manufacturer_id == 0x027A - end + can_handle = require("inverse_valve.can_handle"), } return inverse_valve diff --git a/drivers/SmartThings/zwave-valve/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-valve/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-valve/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-valve/src/sub_drivers.lua b/drivers/SmartThings/zwave-valve/src/sub_drivers.lua new file mode 100644 index 0000000000..b43856e0cd --- /dev/null +++ b/drivers/SmartThings/zwave-valve/src/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("inverse_valve"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-valve/src/test/test_inverse.valve.lua b/drivers/SmartThings/zwave-valve/src/test/test_inverse.valve.lua index db7d6be656..7e53d6e943 100644 --- a/drivers/SmartThings/zwave-valve/src/test/test_inverse.valve.lua +++ b/drivers/SmartThings/zwave-valve/src/test/test_inverse.valve.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -57,6 +47,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 14 } ) @@ -75,6 +68,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 14 } ) @@ -93,6 +89,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 14 } ) @@ -111,6 +110,9 @@ test.register_message_test( direction = "send", message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 14 } ) @@ -141,7 +143,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -171,7 +176,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-valve/src/test/test_zwave_valve.lua b/drivers/SmartThings/zwave-valve/src/test/test_zwave_valve.lua index 4a21568119..1b689e6903 100644 --- a/drivers/SmartThings/zwave-valve/src/test/test_zwave_valve.lua +++ b/drivers/SmartThings/zwave-valve/src/test/test_zwave_valve.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -79,6 +69,9 @@ test.register_message_test( direction = "send", message = mock_valve_binary:generate_test_message("main", capabilities.valve.valve.open()) } + }, + { + min_api_version = 14 } ) @@ -95,6 +88,9 @@ test.register_message_test( direction = "send", message = mock_valve_binary:generate_test_message("main", capabilities.valve.valve.closed()) } + }, + { + min_api_version = 14 } ) @@ -124,7 +120,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -154,7 +151,8 @@ test.register_message_test( }, }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -185,7 +183,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -215,7 +216,10 @@ test.register_coroutine_test( Basic:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -245,7 +249,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -275,7 +282,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() 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-virtual-momentary-switch/src/test/test_zwave_virtual_momentary_switch.lua b/drivers/SmartThings/zwave-virtual-momentary-switch/src/test/test_zwave_virtual_momentary_switch.lua index dffe6b2f38..0971b8653e 100644 --- a/drivers/SmartThings/zwave-virtual-momentary-switch/src/test/test_zwave_virtual_momentary_switch.lua +++ b/drivers/SmartThings/zwave-virtual-momentary-switch/src/test/test_zwave_virtual_momentary_switch.lua @@ -65,6 +65,9 @@ test.register_message_test( direction = "send", message = mock_momentary_switch:generate_test_message("main", capabilities.switch.switch.on()) } + }, + { + min_api_version = 14 } ) @@ -81,6 +84,9 @@ test.register_message_test( direction = "send", message = mock_momentary_switch:generate_test_message("main", capabilities.switch.switch.off()) } + }, + { + min_api_version = 14 } ) @@ -106,7 +112,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -128,7 +135,8 @@ test.register_message_test( } }, { - inner_block_ordering = "relaxed" + inner_block_ordering = "relaxed", + min_api_version = 14 } ) @@ -176,7 +184,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) @@ -223,7 +234,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -250,7 +264,10 @@ test.register_coroutine_test( SwitchBinary:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() 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 diff --git a/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/can_handle.lua new file mode 100644 index 0000000000..7caf966d19 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_aeotec_nano_shutter(opts, driver, device, ...) + local FINGERPRINTS = require("aeotec-nano-shutter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("aeotec-nano-shutter") + end + end + return false +end + +return can_handle_aeotec_nano_shutter diff --git a/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/fingerprints.lua new file mode 100644 index 0000000000..dcbcf2f872 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/fingerprints.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local AEOTEC_NANO_SHUTTER_FINGERPRINTS = { + {mfr = 0x0086, prod = 0x0003, model = 0x008D}, -- Aeotec nano shutter EU + {mfr = 0x0086, prod = 0x0103, model = 0x008D}, -- Aeotec nano shutter US + {mfr = 0x0371, prod = 0x0003, model = 0x008D}, -- Aeotec nano shutter EU + {mfr = 0x0371, prod = 0x0103, model = 0x008D} -- Aeotec nano shutter US +} + +return AEOTEC_NANO_SHUTTER_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/init.lua b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/init.lua index 6ca6fd7837..82977a7cfb 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/aeotec-nano-shutter/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -28,26 +18,12 @@ local SET_BUTTON_TO_CLOSE = "close" local SET_BUTTON_TO_PAUSE = "pause" local SHADE_STATE = "shade_state" -local AEOTEC_NANO_SHUTTER_FINGERPRINTS = { - {mfr = 0x0086, prod = 0x0003, model = 0x008D}, -- Aeotec nano shutter EU - {mfr = 0x0086, prod = 0x0103, model = 0x008D}, -- Aeotec nano shutter US - {mfr = 0x0371, prod = 0x0003, model = 0x008D}, -- Aeotec nano shutter EU - {mfr = 0x0371, prod = 0x0103, model = 0x008D} -- Aeotec nano shutter US -} --- Determine whether the passed device is proper --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is proper, else false -local function can_handle_aeotec_nano_shutter(opts, driver, device, ...) - for _, fingerprint in ipairs(AEOTEC_NANO_SHUTTER_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end --- Default handler for basic reports for the devices --- @@ -140,7 +116,7 @@ local aeotec_nano_shutter = { added = added_handler }, NAME = "Aeotec nano shutter", - can_handle = can_handle_aeotec_nano_shutter + can_handle = require("aeotec-nano-shutter.can_handle"), } return aeotec_nano_shutter diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/can_handle.lua new file mode 100644 index 0000000000..881ebd36a3 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_iblinds_window_treatment(opts, driver, device, ...) + local FINGERPRINTS = require("iblinds-window-treatment.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("iblinds-window-treatment") + end + end + return false +end + +return can_handle_iblinds_window_treatment diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/fingerprints.lua new file mode 100644 index 0000000000..d4f652f8b9 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IBLINDS_WINDOW_TREATMENT_FINGERPRINTS = { + {mfr = 0x0287, prod = 0x0003, model = 0x000D}, -- iBlinds Window Treatment v1 / v2 + {mfr = 0x0287, prod = 0x0004, model = 0x0071}, -- iBlinds Window Treatment v3 + {mfr = 0x0287, prod = 0x0004, model = 0x0072} -- iBlinds Window Treatment v3.1 +} + +return IBLINDS_WINDOW_TREATMENT_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/init.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/init.lua index 690e50d694..9c3d2d9970 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/init.lua @@ -1,41 +1,18 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ version=3 }) local window_preset_defaults = require "window_preset_defaults" -local IBLINDS_WINDOW_TREATMENT_FINGERPRINTS = { - {mfr = 0x0287, prod = 0x0003, model = 0x000D}, -- iBlinds Window Treatment v1 / v2 - {mfr = 0x0287, prod = 0x0004, model = 0x0071}, -- iBlinds Window Treatment v3 - {mfr = 0x0287, prod = 0x0004, model = 0x0072} -- iBlinds Window Treatment v3.1 -} --- Determine whether the passed device is iblinds window treatment --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is iblinds window treatment, else false -local function can_handle_iblinds_window_treatment(opts, driver, device, ...) - for _, fingerprint in ipairs(IBLINDS_WINDOW_TREATMENT_FINGERPRINTS) do - if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local capability_handlers = {} @@ -91,11 +68,9 @@ local iblinds_window_treatment = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = capability_handlers.preset_position } }, - sub_drivers = { - require("iblinds-window-treatment.v3") - }, + sub_drivers = require("iblinds-window-treatment.sub_drivers"), NAME = "iBlinds window treatment", - can_handle = can_handle_iblinds_window_treatment + can_handle = require("iblinds-window-treatment.can_handle"), } return iblinds_window_treatment diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/sub_drivers.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/sub_drivers.lua new file mode 100644 index 0000000000..187ef0ee04 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require("lazy_load_subdriver") + +return { + lazy_load_if_possible("iblinds-window-treatment.v3") +} diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/can_handle.lua new file mode 100644 index 0000000000..ababe18ac6 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- Determine whether the passed device is iblinds window treatment v3 +--- +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @return boolean true if the device is iblinds window treatment, else false +local function can_handle_iblinds_window_treatment_v3(opts, driver, device, ...) + local FINGERPRINTS = require("iblinds-window-treatment.v3.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("iblinds-window-treatment.v3") + end + end + return false +end + +return can_handle_iblinds_window_treatment_v3 diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/fingerprints.lua new file mode 100644 index 0000000000..ee256ea18a --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local IBLINDS_WINDOW_TREATMENT_FINGERPRINTS_V3 = { + {mfr = 0x0287, prod = 0x0004, model = 0x0071}, -- iBlinds Window Treatment v3 + {mfr = 0x0287, prod = 0x0004, model = 0x0072} -- iBlinds Window Treatment v3 +} + +return IBLINDS_WINDOW_TREATMENT_FINGERPRINTS_V3 diff --git a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3.lua b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/init.lua similarity index 84% rename from drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3.lua rename to drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/init.lua index 9e0a38775d..c0bb8bc363 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/iblinds-window-treatment/v3/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass.SwitchMultilevel diff --git a/drivers/SmartThings/zwave-window-treatment/src/init.lua b/drivers/SmartThings/zwave-window-treatment/src/init.lua index aaaf5c7889..8b8f32c49b 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.defaults @@ -84,12 +74,8 @@ local driver_template = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_preset_defaults.window_shade_preset_cmd, } }, - sub_drivers = { - require("springs-window-fashion-shade"), - require("iblinds-window-treatment"), - require("window-treatment-venetian"), - require("aeotec-nano-shutter") - } + sub_drivers = require("sub_drivers"), + shared_device_thread_enabled = true, } defaults.register_for_default_handlers(driver_template, driver_template.supported_capabilities) diff --git a/drivers/SmartThings/zwave-window-treatment/src/lazy_load_subdriver.lua b/drivers/SmartThings/zwave-window-treatment/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..45115081e4 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/lazy_load_subdriver.lua @@ -0,0 +1,18 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end + +end diff --git a/drivers/SmartThings/zwave-window-treatment/src/preferences.lua b/drivers/SmartThings/zwave-window-treatment/src/preferences.lua index e8854c0bc0..54bad4c0ec 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/preferences.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/preferences.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local devices = { QUBINO = { diff --git a/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/can_handle.lua new file mode 100644 index 0000000000..12d85394cf --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_springs_window_fashion_shade(opts, driver, device, ...) + local FINGERPRINTS = require("springs-window-fashion-shade.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("springs-window-fashion-shade") + end + end + return false +end + +return can_handle_springs_window_fashion_shade diff --git a/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/fingerprints.lua new file mode 100644 index 0000000000..7730b633e8 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local SPRINGS_WINDOW_FINGERPRINTS = { + {mfr = 0x026E, prod = 0x4353, model = 0x5A31}, -- Springs Window Shade + {mfr = 0x026E, prod = 0x5253, model = 0x5A31}, -- Springs Roller Shade +} + +return SPRINGS_WINDOW_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/init.lua b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/init.lua index e501eecdff..2f4d3c38e6 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/springs-window-fashion-shade/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.constants @@ -18,24 +8,12 @@ local constants = require "st.zwave.constants" --- @type st.zwave.CommandClass.SwitchMultilevel local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) -local SPRINGS_WINDOW_FINGERPRINTS = { - {mfr = 0x026E, prod = 0x4353, model = 0x5A31}, -- Springs Window Shade - {mfr = 0x026E, prod = 0x5253, model = 0x5A31}, -- Springs Roller Shade -} --- Determine whether the passed device is springs window fashion shade --- --- @param driver st.zwave.Driver --- @param device st.zwave.Device --- @return boolean true if the device is springs window fashion shade, else false -local function can_handle_springs_window_fashion_shade(opts, driver, device, ...) - for _, fingerprint in ipairs(SPRINGS_WINDOW_FINGERPRINTS) do - if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function init_handler(self, device) -- This device has a preset position set in hardware, so we need to override the base driver @@ -77,7 +55,7 @@ local springs_window_fashion_shade = { } }, NAME = "Springs window fashion shade", - can_handle = can_handle_springs_window_fashion_shade, + can_handle = require("springs-window-fashion-shade.can_handle"), } return springs_window_fashion_shade diff --git a/drivers/SmartThings/zwave-window-treatment/src/sub_drivers.lua b/drivers/SmartThings/zwave-window-treatment/src/sub_drivers.lua new file mode 100644 index 0000000000..6db8853e1a --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("springs-window-fashion-shade"), + lazy_load_if_possible("iblinds-window-treatment"), + lazy_load_if_possible("window-treatment-venetian"), + lazy_load_if_possible("aeotec-nano-shutter"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_fibaro_roller_shutter.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_fibaro_roller_shutter.lua index 9c879806f6..85567ad47d 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_fibaro_roller_shutter.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_fibaro_roller_shutter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -76,6 +66,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 14 } ) @@ -99,6 +92,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 14 } ) @@ -122,6 +118,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 14 } ) @@ -152,6 +151,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 14 } ) @@ -182,6 +184,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 14 } ) @@ -212,6 +217,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 14 } ) @@ -243,7 +251,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -274,7 +285,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -293,7 +307,10 @@ test.register_coroutine_test( SwitchMultilevel:StopLevelChange({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -324,7 +341,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -355,7 +375,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -380,6 +403,9 @@ test.register_message_test( direction = "send", message = mock_fibaro_roller_shutter_venetian:generate_test_message("venetianBlind", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 14 } ) @@ -403,7 +429,10 @@ test.register_coroutine_test( test.socket.zwave:__queue_receive({mock_fibaro_roller_shutter.id, Configuration:Report({ parameter_number = 150, configuration_value = 1 }) }) test.wait_for_events() assert(mock_fibaro_roller_shutter:get_field("calibration") == "done", "Calibration should be done") - end + end, + { + min_api_version = 14 + } ) do @@ -425,7 +454,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -448,7 +481,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -471,7 +508,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -494,7 +535,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -517,7 +562,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -540,7 +589,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -563,7 +616,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -586,7 +643,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -609,7 +670,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -632,7 +697,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -655,7 +724,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -678,7 +751,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -701,7 +778,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -724,7 +805,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -747,7 +832,43 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end +test.register_coroutine_test( + "Configuration:Report for OPERATING_MODE=1 should update to roller shutter profile", + function() + test.socket.zwave:__queue_receive({ + mock_fibaro_roller_shutter_venetian.id, + zw_test_utils.zwave_test_build_receive_command( + Configuration:Report({ parameter_number = 151, configuration_value = 1 }) + ) + }) + mock_fibaro_roller_shutter_venetian:expect_metadata_update({ profile = "fibaro-roller-shutter" }) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Configuration:Report for OPERATING_MODE=2 should update to venetian profile", + function() + test.socket.zwave:__queue_receive({ + mock_fibaro_roller_shutter_venetian.id, + zw_test_utils.zwave_test_build_receive_command( + Configuration:Report({ parameter_number = 151, configuration_value = 2 }) + ) + }) + mock_fibaro_roller_shutter_venetian:expect_metadata_update({ profile = "fibaro-roller-shutter-venetian" }) + end, + { + min_api_version = 14 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_qubino_flush_shutter.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_qubino_flush_shutter.lua index 5431ed1e70..9208b2d198 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_qubino_flush_shutter.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_qubino_flush_shutter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -84,6 +74,9 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 14 } ) @@ -114,6 +107,9 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 14 } ) @@ -144,6 +140,9 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 14 } ) @@ -174,7 +173,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -204,7 +206,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -223,7 +228,10 @@ test.register_coroutine_test( SwitchMultilevel:StopLevelChange({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -254,7 +262,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -284,7 +295,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -309,6 +323,9 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter_venetian:generate_test_message("venetianBlind", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 14 } ) @@ -339,7 +356,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}, {encap = zw.ENCAP.AUTO, src_channel = 0, dst_channels = {2}}) ) ) - end + end, + { + min_api_version = 14 + } ) do @@ -361,7 +381,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -384,7 +408,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -407,7 +435,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -430,7 +462,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -453,7 +489,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -476,7 +516,11 @@ do }) ) ) - end + end, + { + min_api_version = 14 + } + ) end @@ -517,125 +561,134 @@ test.register_coroutine_test( capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, { visibility = { displayed = false }}) ) ) - end + end, + { + min_api_version = 14 + } ) -do - test.register_coroutine_test( - "Mode should be changed to venetian blinds after receiving configuration report with value 1", - function() - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - Configuration:Report({ - parameter_number = 71, - size = 1, - configuration_value = 1 - }) +test.register_coroutine_test( + "Mode should be changed to venetian blinds after receiving configuration report with value 1", + function() + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + Configuration:Report({ + parameter_number = 71, + size = 1, + configuration_value = 1 }) - mock_qubino_flush_shutter:expect_metadata_update({ profile = "qubino-flush-shutter-venetian" }) - end - ) -end + }) + mock_qubino_flush_shutter:expect_metadata_update({ profile = "qubino-flush-shutter-venetian" }) + end, + { + min_api_version = 14 + } +) -do - test.register_coroutine_test( - "Mode should be changed to shutter after receiving configuration report with value 0", - function() - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - Configuration:Report({ - parameter_number = 71, - size = 1, - configuration_value = 0 - }) +test.register_coroutine_test( + "Mode should be changed to shutter after receiving configuration report with value 0", + function() + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + Configuration:Report({ + parameter_number = 71, + size = 1, + configuration_value = 0 }) - mock_qubino_flush_shutter:expect_metadata_update({ profile = "qubino-flush-shutter" }) - end - ) -end + }) + mock_qubino_flush_shutter:expect_metadata_update({ profile = "qubino-flush-shutter" }) + end, + { + min_api_version = 14 + } +) -do - test.register_coroutine_test( - "SwitchMultilevel:Set() should be correctly interpreted by the driver", - function() - local targetValue = 50 - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - SwitchMultilevel:Set({ - value = targetValue - }) +test.register_coroutine_test( + "SwitchMultilevel:Set() should be correctly interpreted by the driver", + function() + local targetValue = 50 + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + SwitchMultilevel:Set({ + value = targetValue }) - local expectedCachedEvent = utils.stringify_table(capabilities.windowShade.windowShade.opening()) - test.wait_for_events() - local actualCachedEvent = utils.stringify_table(mock_qubino_flush_shutter.transient_store.blinds_last_command) - assert(expectedCachedEvent == actualCachedEvent, "driver should cache 'opening' event when targetLevel > currentLevel") - assert(targetValue == mock_qubino_flush_shutter.transient_store.shade_target, "driver should chache correct level value") - end - ) -end + }) + local expectedCachedEvent = utils.stringify_table(capabilities.windowShade.windowShade.opening()) + test.wait_for_events() + local actualCachedEvent = utils.stringify_table(mock_qubino_flush_shutter.transient_store.blinds_last_command) + assert(expectedCachedEvent == actualCachedEvent, "driver should cache 'opening' event when targetLevel > currentLevel") + assert(targetValue == mock_qubino_flush_shutter.transient_store.shade_target, "driver should chache correct level value") + end, + { + min_api_version = 14 + } +) -do - test.register_coroutine_test( - "Meter:Report() with meter_value > 0 should be correctly interpreted by the driver", - function() - local cachedShadesEvent = capabilities.windowShade.windowShade.opening() - local targetValue = 50 - mock_qubino_flush_shutter:set_field("blinds_last_command", cachedShadesEvent) - mock_qubino_flush_shutter:set_field("shade_target", targetValue) - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - Meter:Report({ - scale = Meter.scale.electric_meter.WATTS, - meter_value = 10 - }) +test.register_coroutine_test( + "Meter:Report() with meter_value > 0 should be correctly interpreted by the driver", + function() + local cachedShadesEvent = capabilities.windowShade.windowShade.opening() + local targetValue = 50 + mock_qubino_flush_shutter:set_field("blinds_last_command", cachedShadesEvent) + mock_qubino_flush_shutter:set_field("shade_target", targetValue) + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 10 }) - test.socket.capability:__expect_send( - mock_qubino_flush_shutter:generate_test_message("main", cachedShadesEvent) - ) - test.socket.capability:__expect_send( - mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(targetValue)) - ) - test.socket.capability:__expect_send( - mock_qubino_flush_shutter:generate_test_message("main", capabilities.powerMeter.power({value = 10, unit = "W"})) - ) - end - ) -end + }) + test.socket.capability:__expect_send( + mock_qubino_flush_shutter:generate_test_message("main", cachedShadesEvent) + ) + test.socket.capability:__expect_send( + mock_qubino_flush_shutter:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(targetValue)) + ) + test.socket.capability:__expect_send( + mock_qubino_flush_shutter:generate_test_message("main", capabilities.powerMeter.power({value = 10, unit = "W"})) + ) + end, + { + min_api_version = 14 + } +) -do - test.register_coroutine_test( - "Meter:Report() with meter_value == 0 should be correctly interpreted by the driver", - function() - test.wait_for_events() - test.socket.zwave:__queue_receive({ - mock_qubino_flush_shutter.id, - Meter:Report({ - scale = Meter.scale.electric_meter.WATTS, - meter_value = 0 - }) + +test.register_coroutine_test( + "Meter:Report() with meter_value == 0 should be correctly interpreted by the driver", + function() + test.wait_for_events() + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter.id, + Meter:Report({ + scale = Meter.scale.electric_meter.WATTS, + meter_value = 0 }) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_qubino_flush_shutter, - SwitchMultilevel:Get({}) - ) - ) - test.socket.zwave:__expect_send( - zw_test_utils.zwave_test_build_send_command( - mock_qubino_flush_shutter, - Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}) - ) + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter, + SwitchMultilevel:Get({}) ) - test.socket.capability:__expect_send( - mock_qubino_flush_shutter:generate_test_message("main", capabilities.powerMeter.power({value = 0, unit = "W"})) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter, + Meter:Get({scale = Meter.scale.electric_meter.KILOWATT_HOURS}) ) - end - ) -end + ) + test.socket.capability:__expect_send( + mock_qubino_flush_shutter:generate_test_message("main", capabilities.powerMeter.power({value = 0, unit = "W"})) + ) + end, + { + min_api_version = 14 + } +) test.register_message_test( "Energy meter reports should be generating events", @@ -658,7 +711,71 @@ test.register_message_test( direction = "send", message = mock_qubino_flush_shutter:generate_test_message("main", capabilities.energyMeter.energy({value = 50, unit = "kWh"})) } + }, + { + min_api_version = 14 } ) +test.register_coroutine_test( + "SwitchMultilevel:Set with lower target than current should cache closing command and fire Meter:Get after 4s", + function() + -- Pre-set state so currentLevel (80) > targetLevel (10) + mock_qubino_flush_shutter_venetian:update_state_cache_entry( + "main", capabilities.windowShadeLevel.ID, "shadeLevel", { value = 80 } + ) + test.timer.__create_and_queue_test_time_advance_timer(4, "oneshot") + test.socket.zwave:__queue_receive({ + mock_qubino_flush_shutter_venetian.id, + SwitchMultilevel:Set({ value = 10 }) + }) + test.wait_for_events() + test.mock_time.advance_time(4) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter_venetian, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + ) + end, + { + min_api_version = 15 + } +) + +do + local new_param_value = 1 + test.register_coroutine_test( + "infoChanged on venetian qubino should send Configuration:Set then Configuration:Get after 1s", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + local device_data = utils.deep_copy(mock_qubino_flush_shutter_venetian.raw_st_data) + device_data.preferences["operatingModes"] = new_param_value + local device_data_json = dkjson.encode(device_data) + test.socket.device_lifecycle:__queue_receive({ mock_qubino_flush_shutter_venetian.id, "infoChanged", device_data_json }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter_venetian, + Configuration:Set({ + parameter_number = 71, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_qubino_flush_shutter_venetian, + Configuration:Get({ parameter_number = 71 }) + ) + ) + end, + { + min_api_version = 14 + } + + ) +end + test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_aeotec_nano_shutter.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_aeotec_nano_shutter.lua index c11b578b07..b2b968750c 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_aeotec_nano_shutter.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_aeotec_nano_shutter.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local zw = require "st.zwave" @@ -56,7 +46,10 @@ test.register_coroutine_test( Basic:Set({ value = 0x00 }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -74,7 +67,10 @@ test.register_coroutine_test( Basic:Set({ value = 0xFF }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -104,7 +100,10 @@ test.register_coroutine_test( Basic:Set({ value = 0x00 }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -140,7 +139,10 @@ test.register_coroutine_test( Basic:Set({ value = 0xFF }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -166,7 +168,10 @@ test.register_coroutine_test( Basic:Set({ value = 0xFF }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -192,7 +197,10 @@ test.register_coroutine_test( Basic:Set({ value = 0x00 }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -230,7 +238,10 @@ test.register_coroutine_test( Basic:Set({ value = 0xFF }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -274,7 +285,10 @@ test.register_coroutine_test( Basic:Set({ value = 0x00 }) ) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -285,7 +299,10 @@ test.register_coroutine_test( test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_window_button, Configuration:Set({parameter_number = 80, size = 1, configuration_value = 1}))) test.socket.zwave:__expect_send(zw_test_utils.zwave_test_build_send_command(mock_window_button, Configuration:Set({parameter_number = 85, size = 1, configuration_value = 1}))) mock_window_button:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 14 + } ) test.register_message_test( @@ -315,6 +332,9 @@ test.register_message_test( Basic:Get({}) ) }, + }, + { + min_api_version = 14 } ) @@ -327,7 +347,10 @@ test.register_coroutine_test( capabilities.statelessCurtainPowerButton.availableCurtainPowerButtons({"open", "close", "pause"}, {visibility = {displayed = false}})) ) - end + end, + { + min_api_version = 14 + } ) test.register_coroutine_test( @@ -344,7 +367,10 @@ test.register_coroutine_test( mock_window_button, Configuration:Set({parameter_number = 35, size = 1, configuration_value = 100}) )) - end + end, + { + min_api_version = 14 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_iblinds_window_treatment.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_iblinds_window_treatment.lua index f53e540fc4..cd68015466 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_iblinds_window_treatment.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_iblinds_window_treatment.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -85,7 +75,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -112,7 +105,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -149,7 +145,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -176,7 +175,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -213,7 +215,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -240,7 +245,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -279,7 +287,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -306,7 +317,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -334,7 +348,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -375,7 +392,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -402,7 +422,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -443,7 +466,10 @@ test.register_coroutine_test( }) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -482,7 +508,64 @@ test.register_coroutine_test( mock_blind_v3, Configuration:Set({parameter_number = 6, size = 1, configuration_value = 50}) )) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting window shade level to 0 on iblinds v1 should emit windowShade.closed", + function() + test.socket.capability:__queue_receive( + { + mock_blind.id, + { capability = "windowShadeLevel", command = "setShadeLevel", args = { 0 } } + } + ) + test.socket.capability:__expect_send( + mock_blind:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_blind:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_blind, + SwitchMultilevel:Set({ value = 0 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting window shade level to 0 on iblinds v3 should emit windowShade.closed", + function() + test.socket.capability:__queue_receive( + { + mock_blind_v3.id, + { capability = "windowShadeLevel", command = "setShadeLevel", args = { 0 } } + } + ) + test.socket.capability:__expect_send( + mock_blind_v3:generate_test_message("main", capabilities.windowShade.windowShade.closed()) + ) + test.socket.capability:__expect_send( + mock_blind_v3:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_blind_v3, + SwitchMultilevel:Set({ value = 0 }) + ) + ) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_springs_window_treatment.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_springs_window_treatment.lua index be3b3a6405..c6c0ca263f 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_springs_window_treatment.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_springs_window_treatment.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local constants = require "st.zwave.constants" @@ -73,7 +63,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_window_treatment.lua b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_window_treatment.lua index 534f6c56f7..c5ecd837d4 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_window_treatment.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/test/test_zwave_window_treatment.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" @@ -89,6 +79,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_basic:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 17 } ) @@ -112,6 +105,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_basic:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -135,6 +131,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_basic:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 17 } ) @@ -165,6 +164,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) } + }, + { + min_api_version = 17 } ) @@ -195,6 +197,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) } + }, + { + min_api_version = 17 } ) @@ -225,6 +230,9 @@ test.register_message_test( direction = "send", message = mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) } + }, + { + min_api_version = 17 } ) @@ -256,7 +264,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -287,7 +298,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -306,7 +320,10 @@ test.register_coroutine_test( SwitchMultilevel:StopLevelChange({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -337,7 +354,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -368,7 +388,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -394,7 +417,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShade.windowShade.open())) test.socket.capability:__expect_send(mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -420,7 +446,10 @@ test.register_coroutine_test( ) test.socket.capability:__expect_send(mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShade.windowShade.closed())) test.socket.capability:__expect_send(mock_window_shade_switch_multilevel:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0))) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -459,7 +488,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) @@ -499,7 +531,10 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -538,7 +573,55 @@ test.register_coroutine_test( SwitchMultilevel:Get({}) ) ) - end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Setting window shade preset on basic-only device should generate Basic:Set and Basic:Get", + function() + test.timer.__create_and_queue_test_time_advance_timer(5, "oneshot") + test.socket.capability:__queue_receive( + { + mock_window_shade_basic.id, + { capability = "windowShadePreset", component = "main", command = "presetPosition", args = {} } + } + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_window_shade_basic, + Basic:Set({ value = 50 }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(5) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_window_shade_basic, + Basic:Get({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Adding a window treatment device should emit supportedWindowShadeCommands", + function() + test.socket.device_lifecycle():__queue_receive({ mock_window_shade_basic.id, "added" }) + test.socket.capability:__expect_send( + mock_window_shade_basic:generate_test_message("main", capabilities.windowShade.supportedWindowShadeCommands( + {"open", "close", "pause"}, { visibility = { displayed = false } } + )) + ) + end, + { + min_api_version = 17 + } ) test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/can_handle.lua new file mode 100644 index 0000000000..f2eb62ad27 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_window_treatment_venetian(opts, driver, device, ...) + local FINGERPRINTS = require("window-treatment-venetian.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("window-treatment-venetian") + end + end + return false +end + +return can_handle_window_treatment_venetian diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/can_handle.lua new file mode 100644 index 0000000000..cc9ca62b1c --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_fibaro_roller_shutter(opts, driver, device, ...) + local FINGERPRINTS = require("window-treatment-venetian.fibaro-roller-shutter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("window-treatment-venetian.fibaro-roller-shutter") + end + end + return false +end + +return can_handle_fibaro_roller_shutter diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/fingerprints.lua new file mode 100644 index 0000000000..925685157f --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local FIBARO_ROLLER_SHUTTER_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1D01, model = 0x1000}, -- Fibaro Walli Roller Shutter +} + +return FIBARO_ROLLER_SHUTTER_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/init.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/init.lua index dbdde5b87c..5581e3a5af 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fibaro-roller-shutter/init.lua @@ -1,25 +1,12 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- @type st.zwave.CommandClass local cc = (require "st.zwave.CommandClass") --- @type st.zwave.CommandClass.Configuration local Configuration = (require "st.zwave.CommandClass.Configuration")({version=1}) -local FIBARO_ROLLER_SHUTTER_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1D01, model = 0x1000}, -- Fibaro Walli Roller Shutter -} -- configuration parameters local CALIBRATION_CONFIGURATION = 150 @@ -33,14 +20,6 @@ local CLB_NOT_STARTED = "not_started" local CLB_DONE = "done" local CLB_PENDING = "pending" -local function can_handle_fibaro_roller_shutter(opts, driver, device, ...) - for _, fingerprint in ipairs(FIBARO_ROLLER_SHUTTER_FINGERPRINTS) do - if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function configuration_report(driver, device, cmd) local parameter_number = cmd.args.parameter_number @@ -79,7 +58,7 @@ local fibaro_roller_shutter = { } }, NAME = "fibaro roller shutter", - can_handle = can_handle_fibaro_roller_shutter, + can_handle = require("window-treatment-venetian.fibaro-roller-shutter.can_handle"), lifecycle_handlers = { add = device_added } diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fingerprints.lua new file mode 100644 index 0000000000..91dc96c5b8 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/fingerprints.lua @@ -0,0 +1,10 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local WINDOW_TREATMENT_VENETIAN_FINGERPRINTS = { + {mfr = 0x010F, prod = 0x1D01, model = 0x1000}, -- Fibaro Walli Roller Shutter + {mfr = 0x0159, prod = 0x0003, model = 0x0052}, -- Qubino Flush Shutter AC + {mfr = 0x0159, prod = 0x0003, model = 0x0053}, -- Qubino Flush Shutter DC +} + +return WINDOW_TREATMENT_VENETIAN_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/init.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/init.lua index 2ff27250a8..c201ed32eb 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/init.lua @@ -1,16 +1,7 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + + local cc = (require "st.zwave.CommandClass") local Basic = (require "st.zwave.CommandClass.Basic")({ version=1 }) @@ -19,20 +10,7 @@ local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({ver local WindowShadeDefaults = require "st.zwave.defaults.windowShade" local WindowShadeLevelDefaults = require "st.zwave.defaults.windowShadeLevel" -local WINDOW_TREATMENT_VENETIAN_FINGERPRINTS = { - {mfr = 0x010F, prod = 0x1D01, model = 0x1000}, -- Fibaro Walli Roller Shutter - {mfr = 0x0159, prod = 0x0003, model = 0x0052}, -- Qubino Flush Shutter AC - {mfr = 0x0159, prod = 0x0003, model = 0x0053}, -- Qubino Flush Shutter DC -} -local function can_handle_window_treatment_venetian(opts, driver, device, ...) - for _, fingerprint in ipairs(WINDOW_TREATMENT_VENETIAN_FINGERPRINTS) do - if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function shade_event_handler(self, device, cmd) WindowShadeDefaults.zwave_handlers[cc.SWITCH_MULTILEVEL][SwitchMultilevel.REPORT](self, device, cmd) @@ -70,14 +48,11 @@ local window_treatment_venetian = { [SwitchMultilevel.REPORT] = shade_event_handler } }, - can_handle = can_handle_window_treatment_venetian, + can_handle = require("window-treatment-venetian.can_handle"), lifecycle_handlers = { init = map_components }, - sub_drivers = { - require("window-treatment-venetian/fibaro-roller-shutter"), - require("window-treatment-venetian/qubino-flush-shutter") - } + sub_drivers = require("window-treatment-venetian.sub_drivers"), } return window_treatment_venetian diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/can_handle.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/can_handle.lua new file mode 100644 index 0000000000..e601800084 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_qubino_flush_shutter(opts, self, device, ...) + local FINGERPRINTS = require("window-treatment-venetian.qubino-flush-shutter.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then + return true, require("window-treatment-venetian.qubino-flush-shutter") + end + end + return false +end + +return can_handle_qubino_flush_shutter diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/fingerprints.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/fingerprints.lua new file mode 100644 index 0000000000..098879f313 --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/fingerprints.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local QUBINO_FLUSH_SHUTTER_FINGERPRINTS = { + {mfr = 0x0159, prod = 0x0003, model = 0x0052}, -- Qubino Flush Shutter AC + {mfr = 0x0159, prod = 0x0003, model = 0x0053}, -- Qubino Flush Shutter DC +} + +return QUBINO_FLUSH_SHUTTER_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/init.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/init.lua index fafc6d5026..56ba50f516 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/init.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/qubino-flush-shutter/init.lua @@ -1,16 +1,6 @@ --- Copyright 2022 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. +-- Copyright 2022 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local capabilities = require "st.capabilities" --- @type st.zwave.CommandClass @@ -41,19 +31,7 @@ local SHADE_TARGET = "shade_target" local ENERGY_UNIT_KWH = "kWh" local POWER_UNIT_WATT = "W" -local QUBINO_FLUSH_SHUTTER_FINGERPRINTS = { - {mfr = 0x0159, prod = 0x0003, model = 0x0052}, -- Qubino Flush Shutter AC - {mfr = 0x0159, prod = 0x0003, model = 0x0053}, -- Qubino Flush Shutter DC -} -local function can_handle_qubino_flush_shutter(opts, self, device, ...) - for _, fingerprint in ipairs(QUBINO_FLUSH_SHUTTER_FINGERPRINTS) do - if device:id_match( fingerprint.mfr, fingerprint.prod, fingerprint.model) then - return true - end - end - return false -end local function configuration_report(self, device, cmd) local parameter_number = cmd.args.parameter_number @@ -190,7 +168,7 @@ local qubino_flush_shutter = { [capabilities.windowShade.commands.close.NAME] = close }, }, - can_handle = can_handle_qubino_flush_shutter, + can_handle = require("window-treatment-venetian.qubino-flush-shutter.can_handle"), lifecycle_handlers = { added = device_added, infoChanged = info_changed diff --git a/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/sub_drivers.lua b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/sub_drivers.lua new file mode 100644 index 0000000000..41600cffef --- /dev/null +++ b/drivers/SmartThings/zwave-window-treatment/src/window-treatment-venetian/sub_drivers.lua @@ -0,0 +1,9 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("window-treatment-venetian/fibaro-roller-shutter"), + lazy_load_if_possible("window-treatment-venetian/qubino-flush-shutter"), +} +return sub_drivers diff --git a/drivers/SmartThings/zwave-window-treatment/src/window_preset_defaults.lua b/drivers/SmartThings/zwave-window-treatment/src/window_preset_defaults.lua index 5046940dea..3d5056abc5 100644 --- a/drivers/SmartThings/zwave-window-treatment/src/window_preset_defaults.lua +++ b/drivers/SmartThings/zwave-window-treatment/src/window_preset_defaults.lua @@ -1,16 +1,6 @@ --- Copyright 2025 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. +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + -- These were added to scripting engine, but this file is to make sure drivers @@ -62,4 +52,4 @@ defaults.window_shade_preset_cmd = function(driver, device, command) device.thread:call_with_delay(constants.MIN_DIMMING_GET_STATUS_DELAY, query_device) end -return defaults \ No newline at end of file +return defaults diff --git a/drivers/Unofficial/tuya-zigbee/fingerprints.yml b/drivers/Unofficial/tuya-zigbee/fingerprints.yml index 7ee7460696..5c77617d1d 100644 --- a/drivers/Unofficial/tuya-zigbee/fingerprints.yml +++ b/drivers/Unofficial/tuya-zigbee/fingerprints.yml @@ -43,4 +43,9 @@ zigbeeManufacturer: deviceLabel: Tuya Switch 1 manufacturer: _TZE204_h2rctifa model: TS0601 - deviceProfileName: basic-switch \ No newline at end of file + deviceProfileName: basic-switch + - id: _TZE284_fziifcxj/TS0601 + deviceLabel: Tuya Thermostat + manufacturer: _TZE284_fziifcxj + model: TS0601 + deviceProfileName: thermostat diff --git a/drivers/Unofficial/tuya-zigbee/profiles/thermostat.yml b/drivers/Unofficial/tuya-zigbee/profiles/thermostat.yml new file mode 100644 index 0000000000..66ecb5661b --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/profiles/thermostat.yml @@ -0,0 +1,24 @@ +name: thermostat +components: + - id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + step: 0.5 + - id: thermostatMode + version: 1 + config: + values: + - key: "thermostatMode.value" + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml b/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml index 9712eb6849..d0f47cdafd 100644 --- a/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml +++ b/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: windowShadePreset version: 1 - id: firmwareUpdate diff --git a/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua b/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua index 03c34cac65..284486e88b 100644 --- a/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua +++ b/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua @@ -23,6 +23,14 @@ local packet_id = 0 local PRESET_LEVEL = 50 local PRESET_LEVEL_KEY = "_presetLevel" +-- When the curtain is moving, LATEST_TARGET_LEVEL is used to store the latest target position value, +-- which will be cleared after 3 seconds timeout +local LATEST_TARGET_LEVEL = "_latestTargetLevel" +-- Field name for the timer that clears the target position after timeout +local TARGET_LEVEL_TIME_OUT = "_targetLevelTimeOut" +-- Timeout for the stateless step to accumulate +local TARGET_LEVEL_TIME_OUT_SECONDS = 15 + local FINGERPRINTS = { { mfr = "_TZE284_nladmfvf", model = "TS0601"} } @@ -153,6 +161,36 @@ local function tuya_cluster_handler(driver, device, zb_rx) end end +local function handle_step_shade_level(driver, device, command) + local step = command.args.stepSize or command.args[1] or (command.positional_args and command.positional_args.stepSize) + + -- When LATEST_TARGET_LEVEL is empty, it means the curtain motor is not moving, + -- and capabilities.windowShadeLevel.shadeLevel is used as the reference for position offset calculation; + -- when LATEST_TARGET_LEVEL is not empty, it means the curtain motor is moving, + -- and LATEST_TARGET_LEVEL is used as the reference for position offset calculation to obtain a more accurate position calculation. + local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) + local current_level = latest_target_level or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + + local target_level = utils.clamp_value(current_level + step, 0, 100) + target_level = utils.round(target_level) + -- Cancel any previous timer and set a 15 second timeout timer to ensure target_level is cleared + -- This is to prevent the stateless step from accumulating indefinitely, while ensuring that a single + -- "scroll" action can accumulate multiple steps within a short time frame + if device:get_field(TARGET_LEVEL_TIME_OUT) then + device.thread:cancel_timer(device:get_field(TARGET_LEVEL_TIME_OUT)) + end + local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) + device:set_field(LATEST_TARGET_LEVEL, nil) + end) + device:set_field(TARGET_LEVEL_TIME_OUT, timer) + device:set_field(LATEST_TARGET_LEVEL, target_level) + + local new_command = { args = { shadeLevel = target_level }, component = command.component } + window_shade_level(driver, device, new_command) +end + local tuya_curtain_driver = { NAME = "tuya curtain", lifecycle_handlers = { @@ -170,6 +208,9 @@ local tuya_curtain_driver = { [capabilities.windowShadeLevel.ID] = { [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = handle_step_shade_level + }, [capabilities.windowShadePreset.ID] = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset, [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = set_preset_position_cmd diff --git a/drivers/Unofficial/tuya-zigbee/src/init.lua b/drivers/Unofficial/tuya-zigbee/src/init.lua index 8fb452abb2..6313132933 100644 --- a/drivers/Unofficial/tuya-zigbee/src/init.lua +++ b/drivers/Unofficial/tuya-zigbee/src/init.lua @@ -26,7 +26,8 @@ local unofficial_tuya_driver_template = { require("curtain"), require("motion-sensor"), require("smoke-detector"), - require("switch") + require("switch"), + require("thermostat") }, health_check = false, } diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua index ca09608347..23f96507ca 100644 --- a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_curtain.lua @@ -59,7 +59,10 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_simple_device.id, zigbee_test_utils.build_bind_request(mock_simple_device, zigbee_test_utils.mock_hub_eui, Basic.ID) }) test.socket.zigbee:__expect_send({ mock_simple_device.id, Basic.attributes.ApplicationVersion:configure_reporting(mock_simple_device, 30, 300, 1) }) mock_simple_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -89,7 +92,10 @@ test.register_coroutine_test( tuya_utils.build_send_tuya_command(mock_simple_device, '\x05', tuya_utils.DP_TYPE_ENUM, '\x00', 1) } ) - end + end, + { + min_api_version = 17 + } ) test.register_coroutine_test( @@ -131,7 +137,10 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_simple_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) ) - end + end, + { + min_api_version = 17 + } ) test.register_message_test( @@ -152,7 +161,10 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade.opening()) } - } + }, + { + min_api_version = 17 + } ) test.register_message_test( @@ -173,7 +185,10 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade.closing()) } - } + }, + { + min_api_version = 17 + } ) test.register_message_test( @@ -189,7 +204,10 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x01', tuya_utils.DP_TYPE_ENUM, '\x01', 0) } } - } + }, + { + min_api_version = 17 + } ) test.register_message_test( @@ -205,7 +223,10 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x3c', 0) } } - } + }, + { + min_api_version = 17 + } ) test.register_message_test( @@ -221,7 +242,10 @@ test.register_message_test( direction = "send", message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x32', 0) } } - } + }, + { + min_api_version = 17 + } ) test.register_message_test( @@ -242,6 +266,191 @@ test.register_message_test( direction = "send", message = mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) } - } + }, + { + min_api_version = 17 + } ) + +test.register_message_test( + "Handle Window Shade step level command - step up", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 } } + }, + { + channel = "zigbee", + direction = "send", + -- For _TZE284_nladmfvf, level is inverted: 100 - 10 = 90 (0x5A) + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x5a', 0) } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle Window Shade step level command - step down", + { + { + channel = "capability", + direction = "receive", + message = { mock_simple_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -10 }, stepSize = -10 } } + }, + { + channel = "zigbee", + direction = "send", + -- For _TZE284_nladmfvf, level is inverted: 100 - 0 = 100 (0x64), but clamped to 0 so 100 - 0 = 100 + message = { mock_simple_device.id, tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x64', 0) } + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Window Shade step level command - step up from current level", + function() + -- First, simulate a current shade level of 40 + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0x01) + }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40)) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + ) + test.wait_for_events() + + -- Then send step command with stepSize 20, should result in level 60 + -- For _TZE284_nladmfvf, level is inverted: 100 - 60 = 40 (0x28) + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 }, stepSize = 20 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Window Shade step level command - step up clamped to 100", + function() + -- First, simulate a current shade level of 90 + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x5a', 0x01) + }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(90)) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + ) + test.wait_for_events() + + -- Then send step command with stepSize 20, should be clamped to 100 + -- For _TZE284_nladmfvf, level is inverted: 100 - 100 = 0 (0x00) + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 }, stepSize = 20 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x00', 0) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Window Shade step level command - step down clamped to 0", + function() + -- First, simulate a current shade level of 10 + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x0a', 0x01) + }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(10)) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + ) + test.wait_for_events() + + -- Then send step command with stepSize -20, should be clamped to 0 + -- For _TZE284_nladmfvf, level is inverted: 100 - 0 = 100 (0x64) + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 }, stepSize = -20 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x64', 0) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Handle Window Shade step level command - uses latest_target_level when available", + function() + -- First, simulate a current shade level of 40 + test.socket.zigbee:__queue_receive({ + mock_simple_device.id, + tuya_utils.build_test_attr_report(mock_simple_device, '\x03', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 0x01) + }) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(40)) + ) + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message("main", capabilities.windowShade.windowShade("partially open")) + ) + test.wait_for_events() + + -- Send first step command to set latest_target_level to 50 + -- For _TZE284_nladmfvf, level is inverted: 100 - 50 = 50 (0x32) + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x32', 0) + }) + test.wait_for_events() + + -- Send second step command before timeout - should use latest_target_level (50) as base + -- Target = 50 + 10 = 60, For _TZE284_nladmfvf: 100 - 60 = 40 (0x28) + -- Packet ID is 1 because it was incremented after the first step command + test.socket.capability:__queue_receive({ + mock_simple_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }, stepSize = 10 } + }) + test.socket.zigbee:__expect_send({ + mock_simple_device.id, + tuya_utils.build_send_tuya_command(mock_simple_device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00\x00\x28', 1) + }) + end, + { + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_switch.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_switch.lua index 97537c36ad..da6c87843a 100644 --- a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_switch.lua +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_switch.lua @@ -254,6 +254,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x02', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } } + }, + { + min_api_version = 17 } ) @@ -270,6 +273,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x03', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } } + }, + { + min_api_version = 17 } ) @@ -286,6 +292,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x04', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } } + }, + { + min_api_version = 17 } ) @@ -302,6 +311,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x05', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } } + }, + { + min_api_version = 17 } ) @@ -318,6 +330,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x06', tuya_utils.DP_TYPE_BOOL, '\x01', 0) } } + }, + { + min_api_version = 17 } ) @@ -334,6 +349,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x01', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } } + }, + { + min_api_version = 17 } ) @@ -350,6 +368,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x02', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } } + }, + { + min_api_version = 17 } ) @@ -366,6 +387,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x03', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } } + }, + { + min_api_version = 17 } ) @@ -382,6 +406,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x04', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } } + }, + { + min_api_version = 17 } ) @@ -398,6 +425,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x05', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } } + }, + { + min_api_version = 17 } ) @@ -414,6 +444,9 @@ test.register_message_test( direction = "send", message = { mock_parent_device.id, tuya_utils.build_send_tuya_command(mock_parent_device, '\x06', tuya_utils.DP_TYPE_BOOL, '\x00', 0) } } + }, + { + min_api_version = 17 } ) @@ -460,4 +493,4 @@ test.register_coroutine_test( end ) -test.run_registered_tests() \ No newline at end of file +test.run_registered_tests() diff --git a/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_thermostat.lua b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_thermostat.lua new file mode 100644 index 0000000000..6198f22f94 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/test/test_tuya_thermostat.lua @@ -0,0 +1,557 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" + +local Basic = clusters.Basic + +local mock_simple_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("thermostat.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "_TZE284_fziifcxj", + model = "TS0601", + server_clusters = { 0xEF00 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_simple_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle doConfigure lifecycle event", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, tuya_utils.build_tuya_magic_spell_message(mock_simple_device) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, Basic.attributes.ApplicationVersion:configure_reporting(mock_simple_device, 30, 300, 1) }) + test.socket.zigbee:__expect_send({ mock_simple_device.id, zigbee_test_utils.build_bind_request(mock_simple_device, zigbee_test_utils.mock_hub_eui, Basic.ID) }) + mock_simple_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + {} +) + +test.register_coroutine_test( + "Handle added lifecycle event", + function() + test.socket.device_lifecycle:__queue_receive({ mock_simple_device.id, "added" }) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.supportedThermostatModes( + { + capabilities.thermostatMode.thermostatMode.antifreezing.NAME, + capabilities.thermostatMode.thermostatMode.auto.NAME, + capabilities.thermostatMode.thermostatMode.comfort.NAME, + capabilities.thermostatMode.thermostatMode.eco.NAME, + capabilities.thermostatMode.thermostatMode.off.NAME, + capabilities.thermostatMode.thermostatMode.on.NAME, + }, + { + visibility = { displayed = false } + } + ) + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 15.0, unit = "C"}) + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.temperatureMeasurement.temperature({value = 20.0, unit = "C"}) + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.auto() + ) + ) + + test.socket.capability:__expect_send( + mock_simple_device:generate_test_message( + "main", + capabilities.battery.battery(100) + ) + ) + end, + {} +) + +test.register_message_test( + "Handle thermostatHeatingSetpoint setHeatingSetpoint", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {12.5} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x04", + tuya_utils.DP_TYPE_VALUE, + "\x00\x00\x00\x7D", + 0x00 + ) + } + } + }, + {} +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (auto)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"auto"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x00", + 0x00 + ) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (off)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"off"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x01", + 0x00 + ) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (on)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"on"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x02", + 0x00 + ) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (comfort)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"comfort"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x03", + 0x00 + ) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (eco)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"eco"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x04", + 0x00 + ) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle thermostatMode setThermostatMode (antifreezing)", + { + { + channel = "capability", + direction = "receive", + message = { + mock_simple_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {"antifreezing"} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_simple_device.id, + tuya_utils.build_send_tuya_command( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x05", + 0x00 + ) + } + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Handle tuya cluster message report (heatingSetpoint)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x04", + tuya_utils.DP_TYPE_VALUE, + "\x00\x00\x00\x7D", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 12.5, unit = "C"}) + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, auto)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x00", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.auto() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, off)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x01", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.off() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, on)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x02", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.on() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, comfort)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x03", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.comfort() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, eco)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x04", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.eco() + ) + } + }, + {} +) + +test.register_message_test( + "Handle tuya cluster message report (setThermostatMode, antifreezing)", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_simple_device.id, + tuya_utils.build_test_attr_report( + mock_simple_device, + "\x02", + tuya_utils.DP_TYPE_ENUM, + "\x05", + 0x01 + ) + } + }, + { + channel = "capability", + direction = "send", + message = mock_simple_device:generate_test_message( + "main", + capabilities.thermostatMode.thermostatMode.antifreezing() + ) + } + }, + {} +) + +test.run_registered_tests() diff --git a/drivers/Unofficial/tuya-zigbee/src/thermostat/init.lua b/drivers/Unofficial/tuya-zigbee/src/thermostat/init.lua new file mode 100644 index 0000000000..2179fb28a4 --- /dev/null +++ b/drivers/Unofficial/tuya-zigbee/src/thermostat/init.lua @@ -0,0 +1,187 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local defaults = require "st.zigbee.defaults" +local device_management = require "st.zigbee.device_management" +local clusters = require "st.zigbee.zcl.clusters" +local tuya_utils = require "tuya_utils" +local Basic = clusters.Basic +local packet_id = 0 + +local FINGERPRINTS = { + { mfr = "_TZE284_fziifcxj", model = "TS0601"} +} + +local function is_tuya_thermostat(opts, driver, device) + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true + end + end + return false +end + +local function device_added(self, device) + device:emit_event(capabilities.thermostatMode.supportedThermostatModes({ + capabilities.thermostatMode.thermostatMode.antifreezing.NAME, + capabilities.thermostatMode.thermostatMode.auto.NAME, + capabilities.thermostatMode.thermostatMode.comfort.NAME, + capabilities.thermostatMode.thermostatMode.eco.NAME, + capabilities.thermostatMode.thermostatMode.off.NAME, + capabilities.thermostatMode.thermostatMode.on.NAME, + }, { visibility = { displayed = false } })) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint({value = 15.0, unit = "C"})) + device:emit_event(capabilities.temperatureMeasurement.temperature({value = 20.0, unit = "C"})) + device:emit_event(capabilities.thermostatMode.thermostatMode.auto()) + device:emit_event(capabilities.battery.battery(100)) +end + +local function do_configure(driver, device) + -- configure ApplicationVersion to keep device online, tuya hub also uses this attribute + tuya_utils.send_magic_spell(device) + device:send(Basic.attributes.ApplicationVersion:configure_reporting(device, 30, 300, 1)) + device:send(device_management.build_bind_request( + device, + Basic.ID, + driver.environment_info.hub_zigbee_eui + )) +end + +local function increase_packet_id(pid) + return (pid + 1) % 65536 +end + +local function do_refresh(driver, device) + device.log.info("do_refresh called") +end + +local MODE_MAP = { + [capabilities.thermostatMode.thermostatMode.auto.NAME] = "\x00", + [capabilities.thermostatMode.thermostatMode.off.NAME] = "\x01", + [capabilities.thermostatMode.thermostatMode.on.NAME] = "\x02", + [capabilities.thermostatMode.thermostatMode.comfort.NAME] = "\x03", + [capabilities.thermostatMode.thermostatMode.eco.NAME] = "\x04", + [capabilities.thermostatMode.thermostatMode.antifreezing.NAME] = "\x05", +} + +local function set_thermostat_mode(driver, device, command) + local mode_value = MODE_MAP[command.args.mode] + if mode_value ~= nil then + tuya_utils.send_tuya_command(device, "\x02", tuya_utils.DP_TYPE_ENUM, mode_value, packet_id) + packet_id = increase_packet_id(packet_id) + end +end + +local function set_heating_setpoint(driver, device, command) + local value = command.args.setpoint + local setpoint_raw = math.floor(value * 10 + 0.5) + tuya_utils.send_tuya_command( + device, + "\x04", + tuya_utils.DP_TYPE_VALUE, + string.pack(">I4", setpoint_raw), + packet_id + ) + packet_id = increase_packet_id(packet_id) +end + +local function tuya_cluster_handler(driver, device, zb_rx) + local event + local raw = zb_rx.body.zcl_body.body_bytes + local dp = raw:byte(3) + local dp_type = raw:byte(4) + local dp_data_len = string.unpack(">I2", raw:sub(5, 6)) + local dp_data = raw:sub(7, 6 + dp_data_len) + + if dp == 0x04 then -- Target temperature + if dp_type == 0x02 and dp_data_len >= 4 then -- value + local target_temp_raw = string.unpack(">I4", dp_data:sub(1, 4)) + local target_temp = target_temp_raw / 10.0 + event = capabilities.thermostatHeatingSetpoint.heatingSetpoint({ + value = target_temp, + unit = "C" + }) + end + + elseif dp == 0x05 then -- Current temperature + if dp_type == 0x02 and dp_data_len >= 4 then -- value + local temp_raw = string.unpack(">I4", dp_data:sub(1, 4)) + local temp = temp_raw / 10.0 + event = capabilities.temperatureMeasurement.temperature({ + value = temp, + unit = "C" + }) + end + + elseif dp == 0x02 then -- Thermostat mode + if dp_type == 0x04 and dp_data_len >= 1 then -- enum + local mode = dp_data:byte(1) + if mode == 0x00 then -- auto + event = capabilities.thermostatMode.thermostatMode.auto() + elseif mode == 0x01 then -- off + event = capabilities.thermostatMode.thermostatMode.off() + elseif mode == 0x02 then -- on + event = capabilities.thermostatMode.thermostatMode.on() + elseif mode == 0x03 then -- comfort + event = capabilities.thermostatMode.thermostatMode.comfort() + elseif mode == 0x04 then -- eco + event = capabilities.thermostatMode.thermostatMode.eco() + elseif mode == 0x05 then -- antifreezing + event = capabilities.thermostatMode.thermostatMode.antifreezing() + end + end + + elseif dp == 0x06 then -- Battery level + if dp_type == 0x02 and dp_data_len >= 4 then -- value + local battery_level = string.unpack(">I4", dp_data:sub(1, 4)) + event = capabilities.battery.battery(battery_level) + end + end + + if event ~= nil then + device:emit_event(event) + end +end + +local tuya_thermostat_driver = { + NAME = "tuya thermostat", + supported_capabilities = { + capabilities.temperatureMeasurement, + capabilities.thermostatHeatingSetpoint, + capabilities.thermostatMode, + capabilities.battery, + capabilities.refresh + }, + zigbee_handlers = { + cluster = { + [tuya_utils.TUYA_PRIVATE_CLUSTER] = { + [tuya_utils.TUYA_PRIVATE_CMD_REPORT] = tuya_cluster_handler, + [tuya_utils.TUYA_PRIVATE_CMD_RESPONSE] = tuya_cluster_handler, + } + } + }, + capability_handlers = { + [capabilities.thermostatMode.ID] = { + [capabilities.thermostatMode.commands.setThermostatMode.NAME] = set_thermostat_mode, + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + [capabilities.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint, + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + }, + }, + lifecycle_handlers = { + added = device_added, + doConfigure = do_configure + }, + can_handle = is_tuya_thermostat +} + +defaults.register_for_default_handlers( + tuya_thermostat_driver, + tuya_thermostat_driver.supported_capabilities, + {} +) +return tuya_thermostat_driver 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/localizations/cn.csv b/tools/localizations/cn.csv index 952b4a5c44..6c6aac0a81 100644 --- a/tools/localizations/cn.csv +++ b/tools/localizations/cn.csv @@ -120,6 +120,7 @@ Aqara Wireless Mini Switch T1,Aqara 无线开关 T1 "WISTAR WSCMXF Smart Vertical Blind Motor",威仕达智能梦幻帘电机 WSCMXF "WISTAR WSCMXF-LED Smart Vertical Blind Motor",威仕达智能梦幻帘电机 WSCMXF-LED "VIVIDSTORM Smart Screen VWSDSTUST120H",VIVIDSTORM智能幕布 VWSDSTUST120H +"Wi-Fi Relay",无线继电器 "HOPOsmart Window Opener A2230011",HOPOsmart链式开窗器 A2230011 "Yanmi Switch (3 Way)",岩米三位智能开关面板 "Onvis Smart Plug S4EU",Onvis 智能插座S4EU @@ -131,3 +132,30 @@ Aqara Wireless Mini Switch T1,Aqara 无线开关 T1 "WISTAR WSCMXB Smart Curtain Motor",威仕达智能开合帘电机 WSCMXB "WISTAR WSCMXC Smart Curtain Motor",威仕达智能开合帘电机 WSCMXC "WISTAR WSCMXJ Smart Curtain Motor",威仕达智能开合帘电机 WSCMXJ +"HAOJAI Smart Switch 3-key",好家智能三键开关 +"HAOJAI Smart Switch 6-key",好家智能六键开关 +"MultiIR Motion Detector MIR-IR100",麦乐克人体移动传感器MIR-IR100 +"MultiIR Water Leak Sensor MIR-WA100",麦乐克水浸传感器MIR-WA100 +"MultiIR Contact Sensor MIR-MC100",麦乐克门窗开关传感器MIR-MC100 +"MultiIR Smart button MIR-SO100",麦乐克智能按钮MIR-SO100 +"MultiIR Smoke Detector MIR-SM200",麦乐克烟雾报警器MIR-SM200 +"MultiIR Siren MIR-SR100",麦乐克声光报警器MIR-SR100 +"Mirror Series 4x4 1",镜系列4x4 1 +"Mirror Series 3x3 1",镜系列3x3 1 +"Punk Series 3x3 1",朋克系列3x3 1 +"Punk Series 2x2 1",朋克系列2x2 1 +"Punk Series 1x1 1",朋克系列1x1 1 +"Matrix X Series 4x2 1",矩阵系列4x2 1 +"Matrix X Series 3x2 1",矩阵系列3x2 1 +"Matrix X Series 2x2 1",矩阵系列2x2 1 +"Matrix X Series 1x2 1",矩阵系列1x2 1 +"Guanzhi Series 3x3 1",观致系列3x3 1 +"Guanzhi Series 2x2 1",观致系列2x2 1 +"Guanzhi Series 1x1 1",观致系列1x1 1 +"Dynamic Lite Series 4x4 1",灵动L系列4x4 1 +"Dynamic Lite Series 3x3 1",灵动L系列3x3 1 +"Dynamic Lite Series 2x2 1",灵动L系列2x2 1 +"Dynamic Lite Series 1x1 1",灵动L系列1x1 1 +"Mirror Series 1x1 1",镜系列1x1 1 +"Mirror Series 2x2 1",镜系列2x2 1 +"WALLHERO Thermostat ACL-403STC1",墙壁侠温控ACL-403STC1 diff --git a/tools/pre-commit b/tools/pre-commit index c9bc4d4d4f..41a9406bf5 100755 --- a/tools/pre-commit +++ b/tools/pre-commit @@ -28,7 +28,7 @@ for file in $files; do # Check if file is not empty, is a SmartThings driver and has the copyright header if [ -s "$file" ] && [[ "$file" =~ "drivers/SmartThings" ]] \ - && [[ ! $(grep $file -P -e "-{2,} Copyright \d{4}(?:-\d{4})? SmartThings") ]]; then + && [[ ! $(grep $file --text -P -e "-{2,} Copyright (?:© )?\d{4}(?:-\d{4})? SmartThings") ]]; then echo "$file: SmartThings Copyright missing from file" fail=1 fi diff --git a/tools/run_driver_tests.py b/tools/run_driver_tests.py index c797ef5320..9020bba05b 100755 --- a/tools/run_driver_tests.py +++ b/tools/run_driver_tests.py @@ -7,6 +7,7 @@ import argparse from pathlib import Path import junit_xml +import shutil VERBOSITY_TOTALS_ONLY = 0 VERBOSITY_TEST_STATUS_ONLY = 1 @@ -14,7 +15,7 @@ VERBOSITY_ALL_TEST_LOGS = 3 DRIVER_DIR = Path(os.path.abspath(__file__)).parents[1].joinpath("drivers") -LUACOV_CONFIG = DRIVER_DIR.parent.joinpath(".circleci", "config.luacov") +LUACOV_CONFIG = DRIVER_DIR.parent.joinpath("tools", "config.luacov") def find_affected_tests(working_dir, changed_files): affected_tests = [] @@ -29,13 +30,18 @@ def find_affected_tests(working_dir, changed_files): affected_tests = set(affected_tests) return affected_tests -def run_tests(verbosity_level, filter, junit, coverage_files): +def run_tests(verbosity_level, filter, junit, coverage_files, html): owd = os.getcwd() coverage_files = find_affected_tests(owd, coverage_files) failure_files = defaultdict(list) ts = [] 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 @@ -44,9 +50,9 @@ def run_tests(verbosity_level, filter, junit, coverage_files): 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 @@ -115,10 +121,10 @@ def run_tests(verbosity_level, filter, junit, coverage_files): test_status = "" test_logs = "" test_done = False - if re.match("^\s*$", line) is None: + if re.match(r"^\s*$", line) is None: last_line = line - m = re.match("Passed (\d+) of (\d+) tests", last_line) + m = re.match(r"Passed (\d+) of (\d+) tests", last_line) if m is None: failure_files[test_file].append("\n ".join(a.stderr.decode().split("\n"))) test_case = junit_xml.TestCase(test_suite.name) @@ -137,7 +143,29 @@ def run_tests(verbosity_level, filter, junit, coverage_files): test_suite.test_cases = test_cases ts.append(test_suite) if test_file in coverage_files: - subprocess.run("luacov -c={}".format(LUACOV_CONFIG), shell=True) + if html: + driver_name = test_file.parts[-4] + src_path = test_file.parents[1] + drivers_needing_html[driver_name] = src_path + else: + subprocess.run("luacov -c={}".format(LUACOV_CONFIG), shell=True) + + if drivers_needing_html: + coverage_html_dir = DRIVER_DIR.parent.joinpath("tools/coverage_output_html") + try: + os.mkdir(coverage_html_dir) + except FileExistsError: + pass + + for driver_name, src_path in drivers_needing_html.items(): + os.chdir(src_path) + subprocess.run("luacov -c {} --reporter html".format(LUACOV_CONFIG), shell=True) + html_source = Path("luacov.report.out") + html_dest = coverage_html_dir.joinpath(driver_name+"_luacov.report.html") + if html_source.exists(): + shutil.copy(html_source, html_dest) + else: + print(f"Warning: HTML coverage file for {driver_name} not found") total_test_info = "Total unit tests passes: {}/{}".format(total_passes, total_tests) print("#" * len(total_test_info)) @@ -165,6 +193,7 @@ def run_tests(verbosity_level, filter, junit, coverage_files): parser.add_argument("--filter", "-f", type=str, nargs="?", help="only run tests containing the filter value in the path") parser.add_argument("--junit", "-j", type=str, nargs="?", help="output test results in JUnit XML to the specified file") parser.add_argument("--coverage", "-c", nargs="*", help="run code tests with coverage (luacov must be installed) OPTIONAL: restrict files to run coverage tests for") + parser.add_argument("--html", action="store_true", help="Generate HTML coverage reports for the files specified by the coverage argument") args = parser.parse_args() verbosity_level = 0 if args.verbose: @@ -173,5 +202,4 @@ def run_tests(verbosity_level, filter, junit, coverage_files): verbosity_level = 2 elif args.superextraverbose: verbosity_level = 3 - run_tests(verbosity_level, args.filter, args.junit, args.coverage) - + run_tests(verbosity_level, args.filter, args.junit, args.coverage, args.html) diff --git a/tools/run_driver_tests_p.py b/tools/run_driver_tests_p.py index 16180f7682..c8387dcb1c 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) @@ -59,6 +63,12 @@ def run_test(test_file): else: successes += 1 test_cases.append(test_case) + if error and error != "" and len(test_cases) == 0: + failure_output += "\t{} ERROR: test file failed to run\n".format(test_suite_name) + error_case = junit_xml.TestCase("(file error) {}".format(test_suite_name)) + error_case.add_error_info("ERROR", error) + test_cases.append(error_case) + failures += 1 test_suite.test_cases = test_cases return (test_suite, successes, failures, failure_output) diff --git a/tools/update_min_tags.py b/tools/update_min_tags.py new file mode 100644 index 0000000000..1af892b1cc --- /dev/null +++ b/tools/update_min_tags.py @@ -0,0 +1,100 @@ +import argparse +import re +import os +import subprocess + +if os.environ.get("LUA_PATH") == None: + print("LUA_PATH environment variable must be set") + exit(1) +script = 'local v=require(\"version\"); print(v.api)' +LIBS_VERSION = int(os.popen(f"lua -e '{script}'").read().strip()) +print(f"Found lua-libs version: {LIBS_VERSION}") + +TEST_CODE_REGEX = r"(test\.register_(?:coroutine|message)_test\(\s*\"([^\"]+)\"[\s\S]*?min_api_version\s*=\s*)(\d+)([\s\S]*?\))" +TEST_RESULT_REGEX = r"Running test \"(.+?(?=\"))\" \(\d+ of \d+\)\n(PASSED|FAILED)" +TEST_FILE_REGEX = r"Running tests from (\S+.lua)" + +def capture_test_output(test_filter = None) -> str: + command = f"python3 tools/run_driver_tests.py -v".split(" ") + if test_filter != None: + command = f"python3 tools/run_driver_tests.py --filter {test_filter} -v".split(" ") + + print(f"Running command: {command}") + proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + print(f"Done") + return proc.stdout.decode('utf-8') + +def get_results_from_output(output:str) -> dict: + passing = {} + failing = {} + while (m := re.search(TEST_FILE_REGEX,output)): + # print(m.group(1)) + output = output[m.end():] + + passed = [] + failed = [] + while True: + eot_index = len(output) + if eot := (re.search(r"#{10,}", output) or re.search(TEST_FILE_REGEX, output)): + eot_index = eot.start() + + if t := re.search(TEST_RESULT_REGEX,output[:eot_index]): + if t.group(2) == 'PASSED': + passed.append(t.group(1)) + else: + failed.append(t.group(1)) + output = output[t.end():] + else: + output = output[eot_index:] + break + if len(passed) > 0: + passing[m.group(1)] = passed + if len(failed) > 0: + failing[m.group(1)] = failed + return (passing,failing) + +def show_failing_tests(failing): + for filename in failing: + tests = failing[filename] + print(f"Failing tests for: {filename}") + for tn in tests: + print(f" {tn}") + +def update_passing_tests(passing): + for filename in passing: + # print(f"Parsing file: {filename}") + fn = filename + passing_tests = list(passing[filename]) + + def replace_function(match): + if match.group(2) in passing_tests and int(match.group(3)) > LIBS_VERSION: + return f"{match.group(1)}{LIBS_VERSION}{match.group(4)}" + else: + return match.group(0) + + + with open(filename,'r') as fd: + file_contents = fd.read() + + updated_test_file = re.sub(TEST_CODE_REGEX,replace_function,file_contents) + + with open(filename + ".new", 'w') as fd: + fd.write(updated_test_file) + + os.replace(filename + ".new", filename) + print(f"Updated: {filename}") + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description="""Runs driver tests against lua libs, parses the output of the tests results, + and updates the min_api_version of the tests that successfully passed if the test version is + less than the test's min_api_version.NOTE: this CAN NOT be done if test filtering is active. + If enabled, disable this by temporarily removing the code or commenting it out.""") + parser.add_argument("--filter", "-f", help="Filter of tests to run and update min_api_version for. Argument is passed into the tools/run_driver_tests.py --filter ",default=None) + args = parser.parse_args() + + test_output = capture_test_output(args.filter) + passing, failing = get_results_from_output(test_output) + update_passing_tests(passing) + show_failing_tests(failing) +