Skip to content

Conversation

@raman325
Copy link
Collaborator

@raman325 raman325 commented Jan 12, 2026

Summary

This PR introduces a provider abstraction layer to enable support for multiple lock platforms. The architecture allows adding support for additional platforms without modifying core keymaster code.

Breaking change

None. Existing Z-Wave JS configurations continue to work unchanged. The provider is automatically detected and instantiated based on the lock entity's platform.

Proposed change

Architecture

  • Created providers/ module with:
    • _base.py - BaseLockProvider abstract base class and CodeSlot dataclass
    • __init__.py - Provider registry and factory functions
    • zwave_js.py - Z-Wave JS implementation (extracted from coordinator)
    • PROVIDERS.md - Developer guide for implementing new providers

Key Changes

  1. Provider Interface: New BaseLockProvider ABC defines required methods:

    • async_connect() - Connect to lock
    • async_is_connected() - Check connection status
    • async_get_usercodes() - Get all codes from lock
    • async_get_usercode() - Get a specific code (may return cached data)
    • async_refresh_usercode() - Bypass cache and query device directly (for integrations with caching)
    • async_set_usercode() - Set a code slot
    • async_clear_usercode() - Clear a code slot
  2. Capability Flags: Providers declare capabilities:

    • supports_push_updates - Real-time lock/unlock events
    • supports_connection_status - Connection monitoring
  3. Coordinator Refactoring:

    • Removed direct Z-Wave JS imports and API calls
    • Uses provider methods for all lock operations
    • Provider handles event subscription internally
  4. Config Flow Improvements:

    • Now filters lock entities to only show supported platforms
    • Added generic filter_func parameter to _get_entities for extensible filtering
    • Prevents users from selecting unsupported locks
  5. Entity Updates:

    • binary_sensor.py and switch.py use async_has_supported_provider()
    • Connection sensor only created when provider supports it

Files Changed

New Files:

  • providers/__init__.py - Registry and factory
  • providers/_base.py - Base classes
  • providers/zwave_js.py - Z-Wave JS provider
  • providers/PROVIDERS.md - Developer documentation
  • tests/providers/ - Provider-specific tests

Modified Files:

  • coordinator.py - Use provider abstraction
  • lock.py - Added provider field
  • helpers.py - Added async_has_supported_provider()
  • config_flow.py - Filter locks by supported platform
  • binary_sensor.py, switch.py - Use new helper

Test Updates

  • Added cleanup fixture to prevent JSON file corruption between tests
  • Updated fixtures to patch new async_has_supported_provider function
  • Rewrote coordinator event tests for provider callback interface
  • Moved provider tests to tests/providers/ directory
  • All 352 tests pass with 85% coverage

Type of change

  • New feature (which adds functionality)

Additional information

  • Developer guide included for implementing new providers
  • async_refresh_usercode defaults to calling async_get_usercode - only integrations with caching (like Z-Wave JS) need to override

🤖 Generated with Claude Code

@tykeal
Copy link
Contributor

tykeal commented Jan 12, 2026

YAY! @raman325 you're awesome! I'll get this onto my test system as soon as I can! I've been wanting something like this as I've been working with someone that went all Schlage WiFi locks but they are doing short term rentals. I wrote a custom automation that's really fragile to work with the lock and rental control, but if (when) this lands getting a Schlage provider in (after I get home-assistant/core#151014) merged will make it so much better!

@raman325 raman325 force-pushed the feature/provider-abstraction branch from cfe6c5c to 36b5378 Compare January 13, 2026 05:59
@firstof9 firstof9 added the enhancement New feature or request label Jan 13, 2026
@raman325 raman325 force-pushed the feature/provider-abstraction branch from 36b5378 to 1f7fa28 Compare January 18, 2026 14:49
raman325 and others added 4 commits January 18, 2026 12:54
- Create providers/ module with BaseLockProvider abstract base class
- Implement ZWaveJSLockProvider extracting all Z-Wave JS specific code
- Refactor coordinator to use provider methods instead of direct API calls
- Update entity platforms to use provider capabilities (supports_connection_status, etc.)
- Add async_has_supported_provider() and async_get_lock_platform() helpers
- Maintain backward compatibility with existing Z-Wave JS configurations

This is Phase 1-3 of the provider abstraction refactor, enabling future
support for additional lock platforms (ZHA, Zigbee2MQTT) without changes
to the core coordinator logic.

Files added:
- providers/__init__.py: Provider registry and factory functions
- providers/_base.py: BaseLockProvider ABC and CodeSlot dataclass
- providers/zwave_js.py: Z-Wave JS lock provider implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Rename _handle_lock_event_from_provider to _handle_provider_lock_event
- Update test fixtures to patch async_has_supported_provider instead of
  async_using_zwave_js in binary_sensor and switch modules
- Add cleanup fixture to prevent JSON file corruption between tests
- Add provider field to excluded_fields in test_lock_dataclass
- Rewrite coordinator event tests for new provider callback interface
- Add PROVIDERS.md developer guide for implementing new lock providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The callback is async and needs to be scheduled as a task rather than
called directly. Update LockEventCallback type alias to reflect async
signature.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@raman325 raman325 force-pushed the feature/provider-abstraction branch from 1f7fa28 to ff7d1ef Compare January 18, 2026 18:00
raman325 and others added 3 commits January 18, 2026 13:06
The previous cleanup looked for json_kmlocks in .venv, but CI uses
system-wide package installation. Now dynamically finds the testing_config
path from the installed pytest_homeassistant_custom_component package.
Also cleans up after tests, not just before.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove unused TYPE_CHECKING import from providers/__init__.py
- Add noqa for intentional lazy import in _register_providers
- Convert relative imports to absolute in providers modules
- Move ZWaveJSLockProvider conditional import to top level in coordinator
- Move is_platform_supported import to top level in helpers
- Move pytest_homeassistant_custom_component import to top level in conftest
- Fix TRY300: move return to else block in zwave_js provider

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Create test_provider_zwave_js.py with 42 tests covering:
  - Provider properties (domain, push updates, connection status)
  - Connection handling (entity lookup, config entry, client access)
  - Usercode operations (get/set/clear with error handling)
  - Event subscription
  - Diagnostics (node ID, status, platform data)
  - Provider factory functions
  - Integration test for Z-Wave JS notification events

- Add MockProvider class to conftest.py for provider-agnostic testing
- Move Z-Wave JS specific test from test_helpers.py to provider module
- Coverage increased from 78.84% to 81.21%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@codecov-commenter
Copy link

codecov-commenter commented Jan 18, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 81.08808% with 73 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.91%. Comparing base (a9a30ef) to head (d2d4cc5).
⚠️ Report is 13 commits behind head on beta.

Files with missing lines Patch % Lines
custom_components/keymaster/providers/zwave_js.py 83.68% 31 Missing ⚠️
custom_components/keymaster/providers/_base.py 77.63% 17 Missing ⚠️
custom_components/keymaster/coordinator.py 69.38% 15 Missing ⚠️
custom_components/keymaster/switch.py 82.35% 3 Missing ⚠️
custom_components/keymaster/binary_sensor.py 66.66% 2 Missing ⚠️
custom_components/keymaster/providers/__init__.py 93.93% 2 Missing ⚠️
custom_components/keymaster/config_flow.py 75.00% 1 Missing ⚠️
custom_components/keymaster/exceptions.py 50.00% 1 Missing ⚠️
custom_components/keymaster/helpers.py 85.71% 1 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             beta     #538      +/-   ##
==========================================
+ Coverage   80.86%   84.91%   +4.05%     
==========================================
  Files          19       24       +5     
  Lines        2341     2685     +344     
==========================================
+ Hits         1893     2280     +387     
+ Misses        448      405      -43     
Flag Coverage Δ
python 84.91% <81.08%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Add 14 new tests to test_helpers.py achieving 100% coverage:
- Timer expired edge cases (cancel with timer_elapsed, is_running/is_setup/end_time/remaining_seconds when expired)
- _async_using TypeError and kmlock without entity_id cases
- async_has_supported_provider with entity_id parameter
- async_get_lock_platform all branches
- dismiss_persistent_notification function

Add 31 new tests to test_coordinator.py improving coverage from 62% to 67%:
- _encode_pin/_decode_pin roundtrip tests
- _is_slot_active with real KeymasterCodeSlot instances
- File operations (create folder, delete JSON, write config) with error handling
- _dict_to_kmlocks and _kmlocks_to_dict conversion tests

Overall test coverage improved from 81.21% to 84.38%.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@raman325 raman325 force-pushed the feature/provider-abstraction branch from 41cd0b6 to cc9f393 Compare January 18, 2026 20:11
raman325 and others added 6 commits January 19, 2026 02:40
- Move ZWaveJSLockProvider import to top-level in providers/__init__.py
- Remove lazy registration pattern (no longer needed with HA 2025.8.0+)
- Remove try/except import wrapper in coordinator.py
- Replace isinstance checks with domain property checks (we trust our own providers)
- Import ZWAVE_JS_DOMAIN constant instead of hardcoding string

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Move async_get_usercode and async_get_usercode_from_node to base class
as optional methods with default None implementations. This eliminates
the need for type: ignore comments when calling these methods on
providers that support them.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove zwave_js_lock_node and zwave_js_lock_device from KeymasterLock
- Remove ZWAVE_JS_DOMAIN import and usage from coordinator
- Remove backwards compatibility block that set deprecated fields
- Clean up JSON load/save handling for removed fields
- Simplify code slot refresh to use base class API without domain checks

The coordinator is now fully provider-agnostic - all provider interaction
happens through the BaseLockProvider API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Rename ZWaveIntegrationNotConfiguredError to ProviderNotConfiguredError
- Remove unused ZWaveNetworkNotReady exception
- Remove deprecated async_using_zwave_js function and _async_using helper
- Remove ZWAVE_JS_DOMAIN import and ZWAVE_JS_SUPPORTED constant from helpers
- Remove unused mock_using_zwavejs fixture and related tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove unused test fixtures: mock_listdir, mock_listdir_err,
  mock_osremove, mock_osrmdir, mock_osmakedir, mock_os_path_join
- Remove async_get_lock_platform from helpers (not used by production code)
- Remove corresponding tests for removed function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Move test_provider_zwave_js.py to tests/providers/test_zwave_js.py
- Add tests/providers/__init__.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@tykeal
Copy link
Contributor

tykeal commented Jan 19, 2026

I pulled the latest version of this and pushed it onto my test instance.

The good news, the new dynamic dashboards still work ;)

The bad news I see the following in the logs during startup (yes I have debug logs enabled but the ERROR isn't at DEBUG level:

2026-01-19 06:38:05.681 DEBUG (MainThread) [custom_components.keymaster.coordinator] [async_setup] Imported 1 keymaster locks
2026-01-19 06:38:05.683 DEBUG (MainThread) [custom_components.keymaster.coordinator] [unsubscribe_listeners] ZWaveTestLock: Removing all listeners
2026-01-19 06:38:05.683 DEBUG (MainThread) [custom_components.keymaster.coordinator] [update_listeners] ZWaveTestLock: Setting create_listeners to run when HA starts
2026-01-19 06:38:05.684 DEBUG (MainThread) [custom_components.keymaster.coordinator] ================================
2026-01-19 06:38:05.684 DEBUG (MainThread) [custom_components.keymaster.coordinator] [verify_lock_configuration] Verifying 01K4ZADY3CDS5DT5BVPBQYQHXP
2026-01-19 06:38:05.684 DEBUG (MainThread) [custom_components.keymaster.coordinator] ================================
2026-01-19 06:38:05.685 DEBUG (MainThread) [custom_components.keymaster.coordinator] [connect_and_update_lock] ZWaveTestLock: Provider connected (platform: zwave_js)
2026-01-19 06:38:05.685 DEBUG (MainThread) [custom_components.keymaster.coordinator] [update_lock_data] ZWaveTestLock: usercodes count: 30
2026-01-19 06:38:09.038 ERROR (MainThread) [custom_components.keymaster.coordinator] Unexpected error fetching keymaster data
Traceback (most recent call last):
  File "/usr/src/homeassistant/homeassistant/helpers/update_coordinator.py", line 416, in _async_refresh
    self.data = await self._async_update_data()
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/custom_components/keymaster/coordinator.py", line 1604, in _async_update_data
    await self.hass.async_add_executor_job(self._write_config_to_json)
  File "/usr/local/lib/python3.13/concurrent/futures/thread.py", line 59, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/config/custom_components/keymaster/coordinator.py", line 392, in _write_config_to_json
    json.dump(config, jsonfile)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.13/json/__init__.py", line 181, in dump
    for chunk in iterable:
                 ^^^^^^^^
  File "/usr/local/lib/python3.13/json/encoder.py", line 435, in _iterencode
    yield from _iterencode_dict(o, _current_indent_level)
  File "/usr/local/lib/python3.13/json/encoder.py", line 409, in _iterencode_dict
    yield from chunks
  File "/usr/local/lib/python3.13/json/encoder.py", line 409, in _iterencode_dict
    yield from chunks
  File "/usr/local/lib/python3.13/json/encoder.py", line 442, in _iterencode
    o = _default(o)
  File "/usr/local/lib/python3.13/json/encoder.py", line 182, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
                    f'is not JSON serializable')
TypeError: Object of type ZWaveJSLockProvider is not JSON serializable
2026-01-19 06:38:09.304 DEBUG (MainThread) [custom_components.keymaster.coordinator] Finished fetching keymaster data in 3.619 seconds (success: False)

What's more, when I add a PIN I see the same error. The PIN does get set (eventually) but that error does happen when the slot is first modified

The provider object (ZWaveJSLockProvider) was being included when
serializing locks to JSON, causing a TypeError since the provider
is not JSON serializable. Added provider to the list of fields
excluded during JSON export alongside autolock_timer and listeners.

Fixes error reported by tykeal in PR FutureTense#538.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@raman325
Copy link
Collaborator Author

Thanks for testing @tykeal!

Fixed in ab9c410 - the provider field was being included in the JSON serialization. It's now excluded alongside autolock_timer and listeners (which are also non-serializable runtime objects).

raman325 and others added 2 commits January 19, 2026 10:10
- Add blank lines before closing docstring quotes in _base.py
- Fix import order and f-string in compare_lovelace_output.py
- Remove unused pytest import in test_helpers.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@tykeal
Copy link
Contributor

tykeal commented Jan 19, 2026

@raman325 thanks. I pulled the latest version and now I'm getting the following during startup:

2026-01-19 09:00:40.025 INFO (MainThread) [custom_components.keymaster.coordinator] Keymaster v0.0.0 is starting, if you have any issues please report them here: https://github.com/FutureTense/keymaster
2026-01-19 09:00:40.026 DEBUG (SyncWorker_3) [custom_components.keymaster.coordinator] [create_json_folder] json_kmlocks Location: /config/custom_components/keymaster/json_kmlocks
2026-01-19 09:00:40.030 ERROR (MainThread) [homeassistant.config_entries] Error setting up entry ZWaveTestLock for keymaster
Traceback (most recent call last):
  File "/usr/src/homeassistant/homeassistant/config_entries.py", line 762, in __async_setup_with_context
    result = await component.async_setup_entry(hass, self)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/custom_components/keymaster/__init__.py", line 131, in async_setup_entry
    await async_setup_services(hass)
  File "/config/custom_components/keymaster/services.py", line 33, in async_setup_services
    await coordinator.initial_setup()
  File "/config/custom_components/keymaster/coordinator.py", line 108, in initial_setup
    await self._async_setup()
  File "/config/custom_components/keymaster/coordinator.py", line 118, in _async_setup
    imported_config = await self.hass.async_add_executor_job(self._get_dict_from_json_file)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.13/concurrent/futures/thread.py", line 59, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/config/custom_components/keymaster/coordinator.py", line 147, in _get_dict_from_json_file
    config = json.load(jsonfile)
  File "/usr/local/lib/python3.13/json/__init__.py", line 298, in load
    return loads(fp.read(),
        cls=cls, object_hook=object_hook,
        parse_float=parse_float, parse_int=parse_int,
        parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw)
  File "/usr/local/lib/python3.13/json/__init__.py", line 352, in loads
    return _default_decoder.decode(s)
           ~~~~~~~~~~~~~~~~~~~~~~~^^^
  File "/usr/local/lib/python3.13/json/decoder.py", line 345, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.13/json/decoder.py", line 363, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 410 (char 409)

Could this be because of the prior issue? I can revert to the latest version of KM, restart, and then upgrade again if you want me to try that.

@raman325
Copy link
Collaborator Author

@tykeal Yes, that's likely from the corrupted JSON file that was written before the fix. The easiest solution is to delete the corrupted file:

rm /config/custom_components/keymaster/json_kmlocks/keymaster_kmlocks.json

Then restart Home Assistant. Keymaster will recreate the file from scratch using your config entry data.

Alternatively, you could revert to beta, restart (to write a clean JSON), then upgrade again - but deleting the file is simpler.

@tykeal
Copy link
Contributor

tykeal commented Jan 20, 2026

@raman325 ok, I removed the json file and restarted. Everything started worked (after I had to reset my test codes).

Tested and works:

  • Adding and removing codes works
  • Using the date range works

Tested and does not work:
Number of uses does not work (though I think the current release doesn't actually work either). Number never decrements. I did, however, test if it removes the code if it hits zero by manually updated the count and that does work

Not tested:
Custom days of week

raman325 and others added 2 commits January 20, 2026 09:45
Resolve conflicts:
- coordinator.py: keep provider abstraction (no direct zwave_js imports)
- compare_lovelace_output.py: add tempfile import from beta

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The _is_slot_active method now checks for int type instead of float,
so update the test to match.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@raman325
Copy link
Collaborator Author

@tykeal that should be resolved in this PR now. If you and/or @firstof9 want to retest the number of uses entity and custom days of week and confirm that it works, then I think this PR may be ready 🤞🏾

raman325 and others added 5 commits January 20, 2026 10:00
More platform-agnostic name (removes Z-Wave "node" terminology) and
clearer intent that it forces a refresh from the device.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
For integrations without caching, refresh and get are functionally
identical. Only integrations with caching need to override.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add filter_func parameter to _get_entities for optional entity filtering.
Config flow now only shows locks from supported platforms (e.g., Z-Wave JS).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Config flow now filters to supported platforms only, and coordinator
fails setup if provider creation fails. By platform setup time, the
provider is guaranteed to exist, making these checks redundant.

- Remove async_has_supported_provider checks from binary_sensor.py
- Remove async_has_supported_provider if/else wrapper from switch.py
- Remove corresponding test fixture patches

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@raman325 raman325 force-pushed the feature/provider-abstraction branch from fce01d0 to d2d4cc5 Compare January 20, 2026 16:38
@tykeal
Copy link
Contributor

tykeal commented Jan 20, 2026

@raman325 ok... so the number of uses is working.... kinda.

It seems to take about 30 seconds for it to catch up (standard entity refresh time?) So, it's technically possible to use a code multiple times even if it's set for 1 in a very short amount of time.

@raman325
Copy link
Collaborator Author

ah we probably need to write state on setting the native value of the number entity. I actually noticed that but didn't think to fix it

@tykeal
Copy link
Contributor

tykeal commented Jan 20, 2026

Well, with me sitting with my test lock in my lap in a demo "door" it's real easy for me to cheese my way through 2 - 3 cycles on a '1' real quick ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants