python-rako-2025 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.github/dependabot.yml +4 -6
  2. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/CHANGELOG.md +19 -1
  3. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/PKG-INFO +1 -1
  4. python_rako_2025-0.5.1/python_rako/__version__.py +1 -0
  5. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/state.py +19 -3
  6. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_state.py +73 -1
  7. python_rako_2025-0.5.0/python_rako/__version__.py +0 -1
  8. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.coveragerc +0 -0
  9. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.editorconfig +0 -0
  10. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.flake8 +0 -0
  11. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.gitattributes +0 -0
  12. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.github/workflows/ci.yml +0 -0
  13. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.github/workflows/release.yml +0 -0
  14. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.gitignore +0 -0
  15. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.isort.cfg +0 -0
  16. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.pre-commit-config.yaml +0 -0
  17. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/.yamllint +0 -0
  18. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/LICENSE +0 -0
  19. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/Makefile +0 -0
  20. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/README.md +0 -0
  21. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/RELEASING.md +0 -0
  22. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/catalog-info.yaml +0 -0
  23. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/conftest.py +0 -0
  24. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/doc/accessing-the-rako-bridge.pdf +0 -0
  25. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/examples/discover_all_devices.py +0 -0
  26. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/examples/example.py +0 -0
  27. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/examples/listen_status.py +0 -0
  28. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/examples/state_snapshot.py +0 -0
  29. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/examples/ventilation_example.py +0 -0
  30. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/examples/verified_commands.py +0 -0
  31. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/examples/view_updates.py +0 -0
  32. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/pyproject.toml +0 -0
  33. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/__init__.py +0 -0
  34. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/bridge.py +0 -0
  35. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/commands.py +0 -0
  36. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/const.py +0 -0
  37. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/exceptions.py +0 -0
  38. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/helpers.py +0 -0
  39. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/listener.py +0 -0
  40. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/model.py +0 -0
  41. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/pacing.py +0 -0
  42. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/protocol.py +0 -0
  43. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/python_rako/py.typed +0 -0
  44. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/scripts/bump_version.py +0 -0
  45. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/scripts/latency.py +0 -0
  46. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/scripts/listen.py +0 -0
  47. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/scripts/measure_interval.py +0 -0
  48. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/scripts/snapshot.py +0 -0
  49. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/README_TESTING.md +0 -0
  50. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/resources/rako.xml +0 -0
  51. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/resources/rako2.xml +0 -0
  52. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/resources/rako3.xml +0 -0
  53. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_async_lock.py +0 -0
  54. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_bridge.py +0 -0
  55. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_bridge_async_parsing.py +0 -0
  56. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_bridge_state.py +0 -0
  57. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_command_queue.py +0 -0
  58. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_commands.py +0 -0
  59. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_discovery.py +0 -0
  60. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_edge_cases.py +0 -0
  61. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_helpers.py +0 -0
  62. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_listener.py +0 -0
  63. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_measure_interval.py +0 -0
  64. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_model.py +0 -0
  65. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_protocol.py +0 -0
  66. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests/test_xml_parsing.py +0 -0
  67. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests_integration/__init__.py +0 -0
  68. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests_integration/conftest.py +0 -0
  69. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests_integration/test_bridge_info_live.py +0 -0
  70. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests_integration/test_command_live.py +0 -0
  71. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests_integration/test_discovery_live.py +0 -0
  72. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests_integration/test_listener_live.py +0 -0
  73. {python_rako_2025-0.5.0 → python_rako_2025-0.5.1}/tests_integration/test_state_live.py +0 -0
@@ -4,12 +4,10 @@ updates:
4
4
  - package-ecosystem: pip
5
5
  directory: "/"
6
6
  schedule:
7
- interval: daily
8
- time: "06:00"
9
- open-pull-requests-limit: 10
7
+ interval: weekly
8
+ open-pull-requests-limit: 5
10
9
  - package-ecosystem: "github-actions"
11
10
  directory: "/"
12
11
  schedule:
13
- interval: daily
14
- time: "06:00"
15
- open-pull-requests-limit: 10
12
+ interval: weekly
13
+ open-pull-requests-limit: 5
@@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  Nothing yet.
11
11
 
12
+ ## [0.5.1] - 2026-11-04
13
+
14
+ ### Fixed
15
+
16
+ - `BridgeStateSnapshot.reconcile()` no longer wipes rooms that are missing
17
+ from the scene cache. `from_caches()` lists every room in the level table,
18
+ so a missing room arrived with `scene=None`, which `reconcile()` read as a
19
+ missed scene change, and reset the room and its channels to unknown. This is
20
+ the opposite of the documented rule that absence is not evidence. A bridge
21
+ that has just restarted refills its cache one scene selection at a time
22
+ (occupancy-sensor rooms within minutes), so the first non-empty read after a
23
+ restart reset every other room to unknown. Rooms missing from the cache now
24
+ only fill in rooms and channels nothing has reported yet. They keep a faded
25
+ room's `faded_from` guard, which the wipe used to discard, letting a later
26
+ stale cache entry bring back the pre-fade scene. The test for this rule
27
+ passed only because it used an empty level table; it now uses a real one.
28
+
12
29
  ## [0.5.0] - 2026-08-30
13
30
 
14
31
  ### Added
@@ -110,7 +127,8 @@ e.g.:
110
127
  - ...
111
128
  -->
112
129
 
113
- [Unreleased]: https://github.com/SimonLeigh/python-rako/compare/v0.5.0...master
130
+ [Unreleased]: https://github.com/SimonLeigh/python-rako/compare/v0.5.1...master
131
+ [0.5.1]: https://github.com/SimonLeigh/python-rako/compare/v0.5.0...v0.5.1
114
132
  [0.5.0]: https://github.com/SimonLeigh/python-rako/releases/tag/v0.5.0
115
133
 
116
134
  <!--
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-rako-2025
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Asynchronous Python client for Rako Controls Lighting
5
5
  Project-URL: Homepage, https://github.com/SimonLeigh/python-rako
6
6
  Project-URL: Repository, https://github.com/SimonLeigh/python-rako
@@ -0,0 +1 @@
1
+ __version__ = "0.5.1"
@@ -328,9 +328,16 @@ class BridgeStateSnapshot:
328
328
  Had the cache said scene 3, the scene change would have been one we
329
329
  missed, and the room would be rebuilt from the cache instead.
330
330
 
331
- Rooms absent from ``fresh`` are left exactly as they are: the bridge
332
- deletes fade-controlled rooms from its scene cache, so absence is not
333
- evidence of anything.
331
+ Rooms absent from the scene cache are left exactly as they are, whether
332
+ they are missing from ``fresh`` altogether or -- as
333
+ :meth:`from_caches` lists every room in the level table -- present with
334
+ ``scene=None``. Absence is not evidence of anything: the bridge
335
+ deletes fade-controlled rooms from its scene cache by design, and after
336
+ a restart its cache starts empty and refills one scene selection at a
337
+ time, so a read taken then lists only the rooms used since. Treating
338
+ ``None`` as a scene change would wipe every other room to unknown.
339
+ Such a room only fills in rooms and channels we have never heard of,
340
+ as unknown; it never overwrites anything already tracked.
334
341
 
335
342
  **Fades get one extra guard.** A fade sets the room's scene to
336
343
  ``None``, which would otherwise always "differ" from a cached scene and
@@ -351,6 +358,15 @@ class BridgeStateSnapshot:
351
358
  rc: state for rc, state in fresh.channels.items() if rc.room_id == room
352
359
  }
353
360
 
361
+ if fresh_room.scene is None:
362
+ # Not in the scene cache: no news. Keep everything we know --
363
+ # including a faded room's faded_from guard -- and only fill in
364
+ # rooms and channels we have never heard of, as unknown.
365
+ rooms.setdefault(room, replace(fresh_room, updated_at=now))
366
+ for room_channel, state in fresh_channels.items():
367
+ channels.setdefault(room_channel, replace(state, updated_at=now))
368
+ continue
369
+
354
370
  if tracked is not None and tracked.is_faded:
355
371
  if fresh_room.scene == tracked.faded_from:
356
372
  _LOGGER.debug(
@@ -304,7 +304,11 @@ def test_reconcile_does_not_overwrite_a_command_echo():
304
304
  def test_reconcile_leaves_rooms_absent_from_the_cache_alone():
305
305
  """Absence from the scene cache means fade-controlled, not off."""
306
306
  tracked = empty_snapshot().apply(ChannelStatusMessage(9, 1, 200))
307
- fresh = BridgeStateSnapshot.from_caches(SceneCache({6: 1}), LevelCache())
307
+ # The real level table, as get_state_snapshot() passes it: room 9 is then
308
+ # listed in ``fresh`` with scene=None rather than missing from it.
309
+ fresh = BridgeStateSnapshot.from_caches(SceneCache({6: 1}), LEVEL_TABLE)
310
+ assert fresh.room_scene(9) is None
311
+ assert 9 in fresh.rooms
308
312
 
309
313
  reconciled = tracked.reconcile(fresh)
310
314
 
@@ -312,6 +316,74 @@ def test_reconcile_leaves_rooms_absent_from_the_cache_alone():
312
316
  assert reconciled.channel_state(9, 1).source is StateSource.LEVEL_BROADCAST
313
317
 
314
318
 
319
+ def test_reconcile_survives_a_bridge_restart_refilling_its_cache():
320
+ """A restarted bridge's cache lists only the rooms used since the restart.
321
+
322
+ Observed 2026-11-03: the bridge's cache came back empty, then partly
323
+ refilled by occupancy-sensor rooms within minutes. That read is not empty,
324
+ so it passes an emptiness check, yet every other room appears with
325
+ scene=None. None of them changed; none of them may be wiped to unknown.
326
+ """
327
+ tracked = (
328
+ empty_snapshot()
329
+ .apply(SceneStatusMessage(6, 0, 2))
330
+ .apply(ChannelStatusMessage(6, 1, 129))
331
+ .apply(SceneStatusMessage(7, 0, 0))
332
+ )
333
+ refilling = BridgeStateSnapshot.from_caches(SceneCache({9: 1}), LEVEL_TABLE)
334
+
335
+ reconciled = tracked.reconcile(refilling)
336
+
337
+ assert reconciled.room_scene(6) == 2
338
+ assert reconciled.channel_level(6, 1) == 129
339
+ assert reconciled.channel_state(6, 1).source is StateSource.LEVEL_BROADCAST
340
+ assert reconciled.channel_level(6, 2) == 192
341
+ assert reconciled.room_scene(7) == 0
342
+ assert reconciled.channel_level(7, 2) == 0
343
+ # The room that is in the cache is still reconciled normally.
344
+ assert reconciled.room_scene(9) == 1
345
+ assert reconciled.channel_level(9, 1) == 255
346
+
347
+
348
+ def test_reconcile_keeps_the_fade_guard_while_the_room_is_absent():
349
+ """The bridge drops a faded room from its cache; that must not reset the guard.
350
+
351
+ Otherwise the next read that still carries the pre-fade scene would be
352
+ adopted as a "missed scene change" and resurrect levels the fade invalidated.
353
+ """
354
+ tracked = (
355
+ empty_snapshot()
356
+ .apply(SceneStatusMessage(6, 0, 1))
357
+ .apply(FadeMessage(6, 0, direction=FadeDirection.UP, command=CommandType.FADE))
358
+ )
359
+ absent = BridgeStateSnapshot.from_caches(SceneCache({7: 1}), LEVEL_TABLE)
360
+
361
+ after_absent = tracked.reconcile(absent)
362
+
363
+ assert after_absent.rooms[6].is_faded
364
+ assert after_absent.rooms[6].faded_from == 1
365
+
366
+ stale = BridgeStateSnapshot.from_caches(SceneCache({6: 1}), LEVEL_TABLE)
367
+ reconciled = after_absent.reconcile(stale)
368
+
369
+ assert reconciled.room_scene(6) is None
370
+ assert reconciled.channel_level(6, 1) is None
371
+ assert reconciled.channel_state(6, 1).source is StateSource.UNKNOWN_AFTER_FADE
372
+
373
+
374
+ def test_reconcile_adopts_absent_rooms_it_has_never_seen_as_unknown():
375
+ """A room nothing has told us about is added -- as unknown, never off."""
376
+ tracked = empty_snapshot().apply(SceneStatusMessage(6, 0, 1))
377
+ fresh = BridgeStateSnapshot.from_caches(SceneCache({6: 1}), LEVEL_TABLE)
378
+
379
+ reconciled = tracked.reconcile(fresh)
380
+
381
+ assert 9 in reconciled.rooms
382
+ assert reconciled.room_scene(9) is None
383
+ assert reconciled.channel_level(9, 1) is None
384
+ assert reconciled.channel_state(9, 1).source is StateSource.UNKNOWN_AFTER_FADE
385
+
386
+
315
387
  def test_reconcile_adopts_rooms_we_have_never_seen():
316
388
  tracked = empty_snapshot()
317
389
  fresh = BridgeStateSnapshot.from_caches(SceneCache({6: 3}), LEVEL_TABLE)
@@ -1 +0,0 @@
1
- __version__ = "0.5.0"