steamloop 1.1.0__tar.gz → 1.2.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 (29) hide show
  1. {steamloop-1.1.0 → steamloop-1.2.1}/PKG-INFO +8 -2
  2. {steamloop-1.1.0 → steamloop-1.2.1}/README.md +7 -1
  3. {steamloop-1.1.0 → steamloop-1.2.1}/pyproject.toml +1 -1
  4. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/__init__.py +1 -1
  5. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/cli.py +25 -14
  6. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/connection.py +20 -3
  7. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/const.py +1 -0
  8. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop.egg-info/PKG-INFO +8 -2
  9. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_cli.py +65 -3
  10. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_connection.py +37 -2
  11. {steamloop-1.1.0 → steamloop-1.2.1}/LICENSE +0 -0
  12. {steamloop-1.1.0 → steamloop-1.2.1}/setup.cfg +0 -0
  13. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/__main__.py +0 -0
  14. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/certs.py +0 -0
  15. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/exceptions.py +0 -0
  16. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/models.py +0 -0
  17. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop/py.typed +0 -0
  18. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop.egg-info/SOURCES.txt +0 -0
  19. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop.egg-info/dependency_links.txt +0 -0
  20. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop.egg-info/entry_points.txt +0 -0
  21. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop.egg-info/requires.txt +0 -0
  22. {steamloop-1.1.0 → steamloop-1.2.1}/src/steamloop.egg-info/top_level.txt +0 -0
  23. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_certs.py +0 -0
  24. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_const.py +0 -0
  25. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_dunder_main.py +0 -0
  26. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_exceptions.py +0 -0
  27. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_models.py +0 -0
  28. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_pairing.py +0 -0
  29. {steamloop-1.1.0 → steamloop-1.2.1}/tests/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steamloop
3
- Version: 1.1.0
3
+ Version: 1.2.1
4
4
  Summary: Local control for choochoo based thermostats
5
5
  Author-email: "J. Nick Koston" <nick@koston.org>
6
6
  License-Expression: Apache-2.0
@@ -68,7 +68,7 @@ pip install steamloop
68
68
 
69
69
  ### Pairing
70
70
 
71
- Put the thermostat in pairing mode (Menu > Settings > Remote Access > Pair New Device), then:
71
+ Put the thermostat in pairing mode (Menu > Settings > Network > Advanced Setup > Remote Connection > Pair), then:
72
72
 
73
73
  ```bash
74
74
  steamloop 192.168.1.100 --pair
@@ -82,6 +82,12 @@ This saves a pairing file in the current directory with the secret key.
82
82
  steamloop 192.168.1.100
83
83
  ```
84
84
 
85
+ If already paired, you can pass the secret key directly to skip the pairing file:
86
+
87
+ ```bash
88
+ steamloop 192.168.1.100 --key YOUR_SECRET_KEY
89
+ ```
90
+
85
91
  Interactive commands: `status`, `heat <temp>`, `cool <temp>`, `mode <off|auto|cool|heat>`, `fan <auto|on|circulate>`, `eheat <on|off>`, `help`.
86
92
 
87
93
  ## Library Usage
@@ -44,7 +44,7 @@ pip install steamloop
44
44
 
45
45
  ### Pairing
46
46
 
47
- Put the thermostat in pairing mode (Menu > Settings > Remote Access > Pair New Device), then:
47
+ Put the thermostat in pairing mode (Menu > Settings > Network > Advanced Setup > Remote Connection > Pair), then:
48
48
 
49
49
  ```bash
50
50
  steamloop 192.168.1.100 --pair
@@ -58,6 +58,12 @@ This saves a pairing file in the current directory with the secret key.
58
58
  steamloop 192.168.1.100
59
59
  ```
60
60
 
61
+ If already paired, you can pass the secret key directly to skip the pairing file:
62
+
63
+ ```bash
64
+ steamloop 192.168.1.100 --key YOUR_SECRET_KEY
65
+ ```
66
+
61
67
  Interactive commands: `status`, `heat <temp>`, `cool <temp>`, `mode <off|auto|cool|heat>`, `fan <auto|on|circulate>`, `eheat <on|off>`, `help`.
62
68
 
63
69
  ## Library Usage
@@ -4,7 +4,7 @@ requires = [ "setuptools>=77.0.3" ]
4
4
 
5
5
  [project]
6
6
  name = "steamloop"
7
- version = "1.1.0"
7
+ version = "1.2.1"
8
8
  description = "Local control for choochoo based thermostats"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  """Local control for thermostat devices over mTLS."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.2.1"
4
4
 
5
5
  from .connection import ThermostatConnection, load_pairing, save_pairing
6
6
  from .const import DEFAULT_PORT, FanMode, HoldType, ZoneMode
@@ -19,8 +19,8 @@ if TYPE_CHECKING:
19
19
 
20
20
  _LOGGER = logging.getLogger(__name__)
21
21
 
22
- _EHEAT_LABELS = {"": "N/A", "1": "On", "2": "Off"}
23
- _ACTIVE_LABELS = {"": "N/A", "1": "Inactive", "2": "Active"}
22
+ _EHEAT_LABELS = {"": "N/A", "0": "Off", "1": "On"}
23
+ _ACTIVE_LABELS = {"": "N/A", "0": "Inactive", "1": "Idle", "2": "Active"}
24
24
 
25
25
  _HOLD_MAP: dict[str, HoldType] = {
26
26
  "manual": HoldType.MANUAL,
@@ -225,22 +225,30 @@ async def _do_pair(ip: str, port: int) -> None:
225
225
  await _do_monitor(ip, port)
226
226
 
227
227
 
228
- async def _do_monitor(ip: str, port: int) -> None:
228
+ async def _do_monitor(ip: str, port: int, secret_key: str | None = None) -> None:
229
229
  """Run monitoring mode with interactive command loop."""
230
- pairing = await load_pairing(ip)
231
- if not pairing:
232
- print("No pairing found. Run with --pair first.")
233
- return
234
-
235
- print("\n=== MONITORING MODE ===")
236
- print(f"Using saved pairing for {ip}")
230
+ if secret_key is not None:
231
+ print("\n=== MONITORING MODE ===")
232
+ print(f"Using provided secret key for {ip}")
233
+ device_type = "automation"
234
+ device_id = "module"
235
+ else:
236
+ pairing = await load_pairing(ip)
237
+ if not pairing:
238
+ print("No pairing found. Run with --pair or --key first.")
239
+ return
240
+ print("\n=== MONITORING MODE ===")
241
+ print(f"Using saved pairing for {ip}")
242
+ secret_key = pairing["secret_key"]
243
+ device_type = pairing.get("device_type", "automation")
244
+ device_id = pairing.get("device_id", "module")
237
245
 
238
246
  conn = ThermostatConnection(
239
247
  ip,
240
248
  port,
241
- secret_key=pairing["secret_key"],
242
- device_type=pairing.get("device_type", "automation"),
243
- device_id=pairing.get("device_id", "module"),
249
+ secret_key=secret_key,
250
+ device_type=device_type,
251
+ device_id=device_id,
244
252
  )
245
253
 
246
254
  def on_event(msg: dict[str, Any]) -> None:
@@ -295,6 +303,9 @@ def main() -> None:
295
303
  help=f"Port (default: {DEFAULT_PORT})",
296
304
  )
297
305
  parser.add_argument("--pair", action="store_true", help="Enter pairing mode")
306
+ parser.add_argument(
307
+ "--key", help="Secret key from a previous pairing (skip pairing file lookup)"
308
+ )
298
309
  parser.add_argument("--debug", action="store_true", help="Enable debug logging")
299
310
  args = parser.parse_args()
300
311
 
@@ -308,4 +319,4 @@ def main() -> None:
308
319
  if args.pair:
309
320
  asyncio.run(_do_pair(args.ip, args.port))
310
321
  else:
311
- asyncio.run(_do_monitor(args.ip, args.port))
322
+ asyncio.run(_do_monitor(args.ip, args.port, secret_key=args.key))
@@ -23,6 +23,7 @@ from .const import (
23
23
  CONNECT_TIMEOUT,
24
24
  DEFAULT_PORT,
25
25
  HEARTBEAT_INTERVAL,
26
+ INITIAL_STATE_TIMEOUT,
26
27
  PAIRING_TIMEOUT,
27
28
  RECONNECT_DELAY,
28
29
  RECONNECT_MAX,
@@ -486,6 +487,10 @@ class ThermostatConnection:
486
487
  """
487
488
  Authenticate with the thermostat.
488
489
 
490
+ After receiving the login response, waits for the initial burst
491
+ of state events (zone discovery, temperatures, etc.) to arrive
492
+ before returning. This ensures ``state`` is fully populated.
493
+
489
494
  Returns:
490
495
  LoginResponse on success.
491
496
 
@@ -507,6 +512,7 @@ class ThermostatConnection:
507
512
  )
508
513
  loop = asyncio.get_running_loop()
509
514
  deadline = loop.time() + RESPONSE_TIMEOUT
515
+ login_resp: LoginResponse | None = None
510
516
  while (remaining := deadline - loop.time()) > 0:
511
517
  try:
512
518
  msg = await asyncio.wait_for(queue.get(), timeout=remaining)
@@ -516,19 +522,30 @@ class ThermostatConnection:
516
522
  continue
517
523
  resp = msg["Response"]
518
524
  if "LoginResponse" in resp:
519
- login_resp: LoginResponse = resp["LoginResponse"]
525
+ login_resp = resp["LoginResponse"]
520
526
  if login_resp.get("status") == "1":
521
527
  _LOGGER.info("Authenticated successfully")
522
- return login_resp
528
+ break
523
529
  raise AuthenticationError(f"Authentication failed: {login_resp}")
524
530
  if "Error" in resp:
525
531
  err: ErrorResponse = resp["Error"]
526
532
  raise AuthenticationError(
527
533
  f"Error {err.get('error_type')}: {err.get('description')}"
528
534
  )
535
+ if login_resp is None:
536
+ raise AuthenticationError("No login response received")
537
+ # Drain the initial state burst — the thermostat sends zone
538
+ # discovery events right after the login response. Keep
539
+ # reading until the stream goes quiet so that callers see a
540
+ # fully-populated ``state`` when login() returns.
541
+ while True:
542
+ try:
543
+ await asyncio.wait_for(queue.get(), timeout=INITIAL_STATE_TIMEOUT)
544
+ except TimeoutError:
545
+ break
546
+ return login_resp
529
547
  finally:
530
548
  self._message_queue = None
531
- raise AuthenticationError("No login response received")
532
549
 
533
550
  async def pair(self) -> SetSecretKeyRequest:
534
551
  """
@@ -43,6 +43,7 @@ HEARTBEAT_INTERVAL = 55 # seconds (thermostat expects every 60s)
43
43
  CONNECT_TIMEOUT = 10
44
44
  PAIRING_TIMEOUT = 120
45
45
  RESPONSE_TIMEOUT = 10
46
+ INITIAL_STATE_TIMEOUT = 1 # seconds to wait for initial state after login
46
47
  RECONNECT_DELAY = 5 # initial delay before first reconnect attempt
47
48
  RECONNECT_MAX = 300 # max delay between reconnect attempts (5 minutes)
48
49
  BACKOFF_FACTOR = 2 # multiply delay by this on each failure
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steamloop
3
- Version: 1.1.0
3
+ Version: 1.2.1
4
4
  Summary: Local control for choochoo based thermostats
5
5
  Author-email: "J. Nick Koston" <nick@koston.org>
6
6
  License-Expression: Apache-2.0
@@ -68,7 +68,7 @@ pip install steamloop
68
68
 
69
69
  ### Pairing
70
70
 
71
- Put the thermostat in pairing mode (Menu > Settings > Remote Access > Pair New Device), then:
71
+ Put the thermostat in pairing mode (Menu > Settings > Network > Advanced Setup > Remote Connection > Pair), then:
72
72
 
73
73
  ```bash
74
74
  steamloop 192.168.1.100 --pair
@@ -82,6 +82,12 @@ This saves a pairing file in the current directory with the secret key.
82
82
  steamloop 192.168.1.100
83
83
  ```
84
84
 
85
+ If already paired, you can pass the secret key directly to skip the pairing file:
86
+
87
+ ```bash
88
+ steamloop 192.168.1.100 --key YOUR_SECRET_KEY
89
+ ```
90
+
85
91
  Interactive commands: `status`, `heat <temp>`, `cool <temp>`, `mode <off|auto|cool|heat>`, `fan <auto|on|circulate>`, `eheat <on|off>`, `help`.
86
92
 
87
93
  ## Library Usage
@@ -542,7 +542,7 @@ def test_main_pair_mode() -> None:
542
542
  patch(
543
543
  "steamloop.cli.argparse.ArgumentParser.parse_args",
544
544
  return_value=MagicMock(
545
- ip="192.168.1.100", port=7878, pair=True, debug=False
545
+ ip="192.168.1.100", port=7878, pair=True, key=None, debug=False
546
546
  ),
547
547
  ),
548
548
  patch(
@@ -561,16 +561,37 @@ def test_main_monitor_mode() -> None:
561
561
  patch(
562
562
  "steamloop.cli.argparse.ArgumentParser.parse_args",
563
563
  return_value=MagicMock(
564
- ip="192.168.1.100", port=7878, pair=False, debug=False
564
+ ip="192.168.1.100", port=7878, pair=False, key=None, debug=False
565
565
  ),
566
566
  ),
567
567
  patch(
568
568
  "steamloop.cli._do_monitor",
569
569
  new_callable=lambda: MagicMock(return_value=sentinel),
570
+ ) as mock_monitor,
571
+ patch("steamloop.cli.asyncio.run") as mock_run,
572
+ ):
573
+ main()
574
+ mock_monitor.assert_called_once_with("192.168.1.100", 7878, secret_key=None)
575
+ mock_run.assert_called_once_with(sentinel)
576
+
577
+
578
+ def test_main_monitor_with_key() -> None:
579
+ sentinel = object()
580
+ with (
581
+ patch(
582
+ "steamloop.cli.argparse.ArgumentParser.parse_args",
583
+ return_value=MagicMock(
584
+ ip="192.168.1.100", port=7878, pair=False, key="my-key", debug=False
585
+ ),
570
586
  ),
587
+ patch(
588
+ "steamloop.cli._do_monitor",
589
+ new_callable=lambda: MagicMock(return_value=sentinel),
590
+ ) as mock_monitor,
571
591
  patch("steamloop.cli.asyncio.run") as mock_run,
572
592
  ):
573
593
  main()
594
+ mock_monitor.assert_called_once_with("192.168.1.100", 7878, secret_key="my-key")
574
595
  mock_run.assert_called_once_with(sentinel)
575
596
 
576
597
 
@@ -579,7 +600,7 @@ def test_main_debug_mode() -> None:
579
600
  patch(
580
601
  "steamloop.cli.argparse.ArgumentParser.parse_args",
581
602
  return_value=MagicMock(
582
- ip="192.168.1.100", port=7878, pair=False, debug=True
603
+ ip="192.168.1.100", port=7878, pair=False, key=None, debug=True
583
604
  ),
584
605
  ),
585
606
  patch(
@@ -594,3 +615,44 @@ def test_main_debug_mode() -> None:
594
615
 
595
616
  mock_logging.assert_called_once()
596
617
  assert mock_logging.call_args[1]["level"] == logging.DEBUG
618
+
619
+
620
+ async def test_do_monitor_with_key_skips_pairing_file(
621
+ capsys: pytest.CaptureFixture[str],
622
+ ) -> None:
623
+ """When secret_key is provided, pairing file is not loaded."""
624
+ mock_conn = MagicMock()
625
+ mock_conn.connected = True
626
+ mock_conn.state = ThermostatState()
627
+ mock_conn.add_event_callback = MagicMock(return_value=lambda: None)
628
+
629
+ async def _mock_aenter(self: Any) -> MagicMock:
630
+ return mock_conn
631
+
632
+ mock_conn.__aenter__ = _mock_aenter
633
+ mock_conn.__aexit__ = AsyncMock(return_value=False)
634
+
635
+ with (
636
+ patch(
637
+ "steamloop.cli.load_pairing",
638
+ new_callable=AsyncMock,
639
+ ) as mock_load,
640
+ patch(
641
+ "steamloop.cli.ThermostatConnection",
642
+ return_value=mock_conn,
643
+ ) as mock_cls,
644
+ patch("steamloop.cli.sys.stdin") as mock_stdin,
645
+ ):
646
+ mock_stdin.readline.side_effect = KeyboardInterrupt
647
+ await _do_monitor("192.168.1.100", 7878, secret_key="my-key")
648
+
649
+ mock_load.assert_not_called()
650
+ mock_cls.assert_called_once_with(
651
+ "192.168.1.100",
652
+ 7878,
653
+ secret_key="my-key",
654
+ device_type="automation",
655
+ device_id="module",
656
+ )
657
+ captured = capsys.readouterr()
658
+ assert "provided secret key" in captured.out
@@ -349,7 +349,8 @@ async def test_login_success(connection: ThermostatConnection) -> None:
349
349
  asyncio.get_event_loop().call_soon(lambda: connection._on_message(resp))
350
350
  # Need a small delay for the queue to process
351
351
  task = asyncio.create_task(_feed_response(connection, resp))
352
- result = await connection.login()
352
+ with patch("steamloop.connection.INITIAL_STATE_TIMEOUT", 0.05):
353
+ result = await connection.login()
353
354
  assert result["status"] == "1"
354
355
  await task
355
356
 
@@ -375,6 +376,7 @@ async def test_login_error_response(
375
376
  async def test_login_timeout(connection: ThermostatConnection) -> None:
376
377
  with (
377
378
  patch("steamloop.connection.RESPONSE_TIMEOUT", 0.05),
379
+ patch("steamloop.connection.INITIAL_STATE_TIMEOUT", 0.01),
378
380
  pytest.raises(AuthenticationError, match="No login response"),
379
381
  ):
380
382
  await connection.login()
@@ -700,11 +702,44 @@ async def test_login_skips_non_response(
700
702
  connection._on_message({"Response": {"LoginResponse": {"status": "1"}}})
701
703
 
702
704
  task = asyncio.create_task(_feed())
703
- result = await connection.login()
705
+ with patch("steamloop.connection.INITIAL_STATE_TIMEOUT", 0.05):
706
+ result = await connection.login()
704
707
  assert result["status"] == "1"
705
708
  await task
706
709
 
707
710
 
711
+ async def test_login_drains_initial_state(
712
+ connection: ThermostatConnection,
713
+ ) -> None:
714
+ """Login waits for the initial state burst after LoginResponse."""
715
+
716
+ async def _feed() -> None:
717
+ await asyncio.sleep(0.01)
718
+ connection._on_message({"Response": {"LoginResponse": {"status": "1"}}})
719
+ # Thermostat sends initial state burst after login response
720
+ await asyncio.sleep(0.01)
721
+ connection._on_message({"Event": {"ZoneAdded": {"zone_id": "1"}}})
722
+ connection._on_message(
723
+ {"Event": {"ZoneNameUpdated": {"zone_id": "1", "zone_name": "Main"}}}
724
+ )
725
+ await asyncio.sleep(0.01)
726
+ connection._on_message({"Event": {"ZoneAdded": {"zone_id": "2"}}})
727
+ connection._on_message(
728
+ {"Event": {"ZoneNameUpdated": {"zone_id": "2", "zone_name": "Upstairs"}}}
729
+ )
730
+
731
+ task = asyncio.create_task(_feed())
732
+ with patch("steamloop.connection.INITIAL_STATE_TIMEOUT", 0.15):
733
+ result = await connection.login()
734
+ assert result["status"] == "1"
735
+ # Both zones should be fully populated by the time login returns
736
+ assert "1" in connection.state.zones
737
+ assert "2" in connection.state.zones
738
+ assert connection.state.zones["1"].name == "Main"
739
+ assert connection.state.zones["2"].name == "Upstairs"
740
+ await task
741
+
742
+
708
743
  # ---------------------------------------------------------------------------
709
744
  # SSLCertVerificationError
710
745
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes