phystack-hub-client 6.16.0__tar.gz → 6.18.2__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 (48) hide show
  1. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/CHANGELOG.md +18 -0
  2. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/PKG-INFO +1 -1
  3. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/pyproject.toml +1 -1
  4. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/client.py +12 -2
  5. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/cloud_twins.py +11 -3
  6. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/webrtc/data_channel.py +8 -4
  7. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/webrtc/data_responder.py +4 -2
  8. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/webrtc/media_responder.py +4 -2
  9. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/webrtc/media_stream.py +21 -5
  10. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_client_integration.py +31 -1
  11. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_media_resilience.py +21 -8
  12. phystack_hub_client-6.18.2/tests/test_no_google_stun.py +77 -0
  13. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/.gitignore +0 -0
  14. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/README.md +0 -0
  15. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/examples/browser_test_backend.py +0 -0
  16. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/scripts/python-node-test.sh +0 -0
  17. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/scripts/webrtc-test.sh +0 -0
  18. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/__init__.py +0 -0
  19. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/advisory_validation.py +0 -0
  20. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/instances/__init__.py +0 -0
  21. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/instances/base_instance.py +0 -0
  22. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/instances/edge_instance.py +0 -0
  23. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/instances/peripheral_instance.py +0 -0
  24. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/scoped_hardware_id.py +0 -0
  25. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/__init__.py +0 -0
  26. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/cloud_app_connection.py +0 -0
  27. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/cloud_app_token.py +0 -0
  28. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/phyhub_connection.py +0 -0
  29. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/signals.py +0 -0
  30. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/web_session_code.py +0 -0
  31. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/webrtc/__init__.py +0 -0
  32. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/services/webrtc/media_source.py +0 -0
  33. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/twin_messaging.py +0 -0
  34. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/twin_registry.py +0 -0
  35. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/types/__init__.py +0 -0
  36. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/src/phystack/hub_client/types/twin_types.py +0 -0
  37. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_advisory_validation.py +0 -0
  38. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_cloud_app_token.py +0 -0
  39. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_cloud_twins.py +0 -0
  40. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_communication.py +0 -0
  41. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_data_channel.py +0 -0
  42. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_instance_listeners.py +0 -0
  43. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_media_kinds.py +0 -0
  44. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_media_responder.py +0 -0
  45. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_media_source.py +0 -0
  46. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_peripheral_lifecycle.py +0 -0
  47. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_signals_rollover.py +0 -0
  48. {phystack_hub_client-6.16.0 → phystack_hub_client-6.18.2}/tests/test_web_session_code.py +0 -0
@@ -6,6 +6,24 @@ publishes even minors from `main`, so a Python release is the next even minor at
6
6
  or above the npm version it matches; the entries below name the npm version and
7
7
  PRs each change was ported from.
8
8
 
9
+ ## 6.18.0 — parity with npm 6.16.0 (TECH-1521)
10
+
11
+ ### Changed
12
+
13
+ - Removed the hardcoded Google STUN fallback (npm 6.16.0, ps-platform #973,
14
+ TECH-1516/TECH-1521). `MediaStreamOptions.stun_servers` and
15
+ `DataChannelOptions.stun_servers` now default to the `PHYSTACK_STUN_URL` env
16
+ var when set, otherwise an empty list — with no `ice_servers_provider`
17
+ resolving, connections degrade to host candidates only instead of contacting
18
+ `stun.l.google.com`. All STUN normally comes from the phyhub-minted ICE list
19
+ (`stun:turn-<region>.phystack.com:3478`).
20
+ - Every configuration builder now returns an explicit `RTCConfiguration`
21
+ (possibly with an empty server list) instead of `None`: aiortc substitutes
22
+ its own default — Google STUN — when a peer connection is created without a
23
+ configuration, which would have silently reintroduced the third-party STUN.
24
+ - Guard tests: defaults carry no third-party STUN, and a source scan fails the
25
+ suite if a Google STUN host/port literal ever reappears in the package.
26
+
9
27
  ## 6.16.0 — parity with npm 6.15.1 (TECH-1483)
10
28
 
11
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: phystack-hub-client
3
- Version: 6.16.0
3
+ Version: 6.18.2
4
4
  Summary: Python client for PhyHub - events, actions, DataChannel, and MediaStream
5
5
  Project-URL: Homepage, https://github.com/phystack/phystack-pypi
6
6
  Project-URL: Documentation, https://docs.phystack.com
@@ -7,7 +7,7 @@ name = "phystack-hub-client"
7
7
  # Fixed version group with the Node hub-client (@phystack/hub-client in
8
8
  # phystack/ps-platform): both clients implement the same twin/WebRTC contract
9
9
  # and are released together under the same version.
10
- version = "6.16.0"
10
+ version = "6.18.2"
11
11
  description = "Python client for PhyHub - events, actions, DataChannel, and MediaStream"
12
12
  readme = "README.md"
13
13
  license = "MIT"
@@ -365,8 +365,18 @@ class PhyHubClient:
365
365
 
366
366
  self._connection.on(self.EVENTS.TWIN_MESSAGE, on_twin_message)
367
367
 
368
- def on_twin_updated(payload: dict[str, Any]) -> None:
369
- """Handle twin property update events."""
368
+ def on_twin_updated(payload: dict[str, Any], *_extra: Any) -> None:
369
+ """Handle twin property update events.
370
+
371
+ ``*_extra`` absorbs any trailing positional argument the server
372
+ appends to the ``twinUpdated`` emit. Some phyhub builds send twin
373
+ lifecycle events with a second positional arg (event metadata);
374
+ JS clients silently ignore it, but python-socketio passes every
375
+ positional arg through, so a strict ``(payload)`` signature raises
376
+ "takes 1 positional argument but 2 were given" and DROPS EVERY twin
377
+ update — the device stops seeing settings/desired-property changes
378
+ until restarted. Matches the tolerant ``on_twin_message`` sibling.
379
+ """
370
380
  twin_data = payload.get("data") or payload
371
381
  twin_id = payload.get("twinId") or (twin_data.get("id") if isinstance(twin_data, dict) else None)
372
382
 
@@ -83,15 +83,23 @@ class CloudTwinStore:
83
83
 
84
84
  # --- lifecycle events ---
85
85
 
86
- def apply_created(self, payload: CloudTwinLifecycleEvent) -> None:
86
+ # ``*_extra`` on these three absorbs any trailing positional arg the
87
+ # server appends to a twin lifecycle emit. They are registered directly
88
+ # as socket.io handlers (PhyHubClient cloud-app mode), so a strict
89
+ # ``(payload)`` signature would raise "takes 1 positional argument but 2
90
+ # were given" and silently drop every cloud-twin update — the same defect
91
+ # fixed in PhyHubClient.on_twin_updated. Direct internal callers still
92
+ # pass a single argument.
93
+
94
+ def apply_created(self, payload: CloudTwinLifecycleEvent, *_extra: Any) -> None:
87
95
  """Apply a ``twinCreated`` event and notify ``on_created`` listeners."""
88
96
  self._apply(_KIND_CREATED, payload)
89
97
 
90
- def apply_updated(self, payload: CloudTwinLifecycleEvent) -> None:
98
+ def apply_updated(self, payload: CloudTwinLifecycleEvent, *_extra: Any) -> None:
91
99
  """Apply a ``twinUpdated`` event and notify ``on_updated`` listeners."""
92
100
  self._apply(_KIND_UPDATED, payload)
93
101
 
94
- def apply_deleted(self, payload: CloudTwinLifecycleEvent) -> None:
102
+ def apply_deleted(self, payload: CloudTwinLifecycleEvent, *_extra: Any) -> None:
95
103
  """Apply a ``twinDeleted`` event and notify ``on_deleted`` listeners."""
96
104
  self._apply(_KIND_DELETED, payload)
97
105
 
@@ -39,6 +39,7 @@ from aiortc import (
39
39
  )
40
40
 
41
41
  from phystack.hub_client.services.webrtc.media_stream import (
42
+ _get_default_stun_servers,
42
43
  apply_remote_ice_candidate,
43
44
  build_ice_servers,
44
45
  )
@@ -110,9 +111,9 @@ class DataChannelOptions:
110
111
  """Options for DataChannel connection."""
111
112
 
112
113
  use_stun: bool = True
113
- stun_servers: list[str] = field(
114
- default_factory=lambda: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]
115
- )
114
+ # Static STUN fallback (PHYSTACK_STUN_URL or empty — never third-party;
115
+ # TECH-1521, mirroring npm @phystack/hub-client 6.16.0).
116
+ stun_servers: list[str] = field(default_factory=_get_default_stun_servers)
116
117
  turn_servers: list[dict[str, str]] = field(default_factory=_get_default_turn_servers)
117
118
  ice_transport_policy: str = field(default_factory=_get_default_ice_transport_policy)
118
119
  connection_timeout: float = 15.0
@@ -384,7 +385,10 @@ class DataChannelHandler:
384
385
  if self._is_closed:
385
386
  raise RuntimeError("DataChannelHandler closed during connection attempt")
386
387
 
387
- configuration = RTCConfiguration(iceServers=ice_servers) if ice_servers else None
388
+ # Always pass an explicit configuration: aiortc substitutes ITS OWN
389
+ # default (Google STUN) when given None — an explicit empty list means
390
+ # host candidates only (TECH-1521).
391
+ configuration = RTCConfiguration(iceServers=ice_servers)
388
392
  peer_connection = RTCPeerConnection(configuration=configuration)
389
393
  self._pc = peer_connection
390
394
  self._pending_remote_candidates = []
@@ -536,9 +536,11 @@ class DataChannelResponder:
536
536
  self._clear_connect_timer(peer_key)
537
537
  self._unhealthy_since.pop(peer_key, None)
538
538
 
539
- async def _build_configuration(self) -> Optional[RTCConfiguration]:
539
+ async def _build_configuration(self) -> RTCConfiguration:
540
540
  ice_servers = await resolve_data_channel_ice_servers(
541
541
  self._options.ice_options,
542
542
  provider=self._options.ice_servers_provider,
543
543
  )
544
- return RTCConfiguration(iceServers=ice_servers) if ice_servers else None
544
+ # Explicit configuration always: aiortc's None default is Google STUN
545
+ # (TECH-1521); an empty list means host candidates only.
546
+ return RTCConfiguration(iceServers=ice_servers)
@@ -476,7 +476,7 @@ class MediaStreamResponder:
476
476
  fps=ice_options.video_fps,
477
477
  )
478
478
 
479
- async def _build_configuration(self) -> Optional[RTCConfiguration]:
479
+ async def _build_configuration(self) -> RTCConfiguration:
480
480
  extra_servers: list[dict[str, Any]] = []
481
481
  if self._options.ice_servers_provider:
482
482
  try:
@@ -489,7 +489,9 @@ class MediaStreamResponder:
489
489
  provider_error,
490
490
  )
491
491
  ice_servers = build_ice_servers(self._options.ice_options, extra_servers)
492
- return RTCConfiguration(iceServers=ice_servers) if ice_servers else None
492
+ # Explicit configuration always: aiortc's None default is Google STUN
493
+ # (TECH-1521); an empty list means host candidates only.
494
+ return RTCConfiguration(iceServers=ice_servers)
493
495
 
494
496
  # =========================================================================
495
497
  # Connect-timeout eviction (npm responder-fanout connectTimers parity)
@@ -94,6 +94,20 @@ def _get_default_ice_transport_policy() -> str:
94
94
  return "relay" if os.environ.get("PHYSTACK_TURN_URL") else "all"
95
95
 
96
96
 
97
+ def _get_default_stun_servers() -> list[str]:
98
+ """Static STUN fallback, used only when no ice_servers_provider resolves.
99
+
100
+ Must be OUR coturn (stun:turn-<region>.phystack.com:3478 via
101
+ PHYSTACK_STUN_URL) — never a third-party service like Google STUN
102
+ (TECH-1521, mirroring npm @phystack/hub-client 6.16.0). With nothing
103
+ configured the fallback is host candidates only: same-network connections
104
+ still work, and cross-NAT needs TURN anyway (which bare STUN never
105
+ provided).
106
+ """
107
+ url = os.environ.get("PHYSTACK_STUN_URL")
108
+ return [url] if url else []
109
+
110
+
97
111
  def build_ice_servers(
98
112
  options: "MediaStreamOptions",
99
113
  extra_servers: Optional[list[dict[str, Any]]] = None,
@@ -181,9 +195,7 @@ class MediaStreamOptions:
181
195
  """Options for MediaStream connection."""
182
196
 
183
197
  use_stun: bool = True
184
- stun_servers: list[str] = field(
185
- default_factory=lambda: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]
186
- )
198
+ stun_servers: list[str] = field(default_factory=_get_default_stun_servers)
187
199
  turn_servers: list[dict[str, str]] = field(default_factory=_get_default_turn_servers)
188
200
  ice_transport_policy: str = field(default_factory=_get_default_ice_transport_policy)
189
201
  connection_timeout: float = 15.0
@@ -594,7 +606,7 @@ class MediaStreamHandler:
594
606
  return []
595
607
  return servers or []
596
608
 
597
- async def _build_rtc_configuration(self, use_stun: bool) -> Optional[RTCConfiguration]:
609
+ async def _build_rtc_configuration(self, use_stun: bool) -> RTCConfiguration:
598
610
  """Build the RTCConfiguration for one connection attempt.
599
611
 
600
612
  Provider-supplied servers take precedence (listed first). The per-attempt
@@ -613,7 +625,11 @@ class MediaStreamHandler:
613
625
  ice_servers = build_ice_servers(source_options, extra_servers)
614
626
  if not use_stun or self._options.ice_transport_policy == "relay":
615
627
  ice_servers = keep_turn_servers(ice_servers)
616
- return RTCConfiguration(iceServers=ice_servers) if ice_servers else None
628
+ # Always pass an explicit configuration: aiortc substitutes ITS OWN
629
+ # default (Google STUN) when given None, which would silently
630
+ # reintroduce the third-party STUN this client no longer uses. An
631
+ # explicit empty list means host candidates only (TECH-1521).
632
+ return RTCConfiguration(iceServers=ice_servers)
617
633
 
618
634
  async def _create_peer_connection(self, use_stun: bool) -> RTCPeerConnection:
619
635
  """Create a fresh RTCPeerConnection and wire its track/state handlers."""
@@ -12,6 +12,7 @@ class FakeConnection:
12
12
  self.emitted: list[tuple[str, dict[str, Any]]] = []
13
13
  self.device_id = "device-1"
14
14
  self.ack_response: Optional[dict[str, Any]] = {"status": "success"}
15
+ self.handlers: dict[str, Any] = {}
15
16
 
16
17
  @property
17
18
  def is_connected(self) -> bool:
@@ -22,7 +23,7 @@ class FakeConnection:
22
23
  return True
23
24
 
24
25
  def on(self, event: str, handler: Any) -> None:
25
- return None
26
+ self.handlers[event] = handler
26
27
 
27
28
  async def emit(
28
29
  self, method: str, payload: Any = None, callback: bool = False
@@ -95,6 +96,35 @@ async def test_device_status_cached_for_ten_seconds() -> None:
95
96
  assert status_calls.count("getDeviceStatus") == 1
96
97
 
97
98
 
99
+ def test_twin_updated_handler_tolerates_trailing_positional_arg() -> None:
100
+ """Regression: some phyhub builds emit `twinUpdated` with a second
101
+ positional arg. python-socketio forwards every positional arg, so a
102
+ strict `(payload)` handler raised "takes 1 positional argument but 2 were
103
+ given" and dropped every twin update fleet-wide (edge apps stopped seeing
104
+ settings changes until restarted). The handler must accept and ignore the
105
+ extra arg the way JS clients do."""
106
+ client, connection = make_client()
107
+ client._setup_socket_listeners()
108
+
109
+ on_twin_updated = connection.handlers["twinUpdated"]
110
+ received: list[dict[str, Any]] = []
111
+ # Register the listener directly to keep the test on the handler's arity,
112
+ # not the subscribe machinery (`on_twin_update` spawns an async task).
113
+ client._twin_update_listeners["twin-x"] = {lambda data: received.append(data)}
114
+
115
+ payload = {
116
+ "twinId": "twin-x",
117
+ "data": {"id": "twin-x", "properties": {"desired": {"volume": 5}}},
118
+ }
119
+
120
+ # One positional arg (phyhub master) — the baseline that already worked.
121
+ on_twin_updated(payload)
122
+ # Two positional args (the QA build that triggered the crash) — must not raise.
123
+ on_twin_updated(payload, {"eventId": "evt-1"})
124
+
125
+ assert received == [payload["data"], payload["data"]]
126
+
127
+
98
128
  def test_public_exports_importable() -> None:
99
129
  import phystack.hub_client as hub_client
100
130
 
@@ -63,7 +63,10 @@ def make_handler(bus: FakeTwinMessageBus, options: MediaStreamOptions) -> MediaS
63
63
  # =============================================================================
64
64
 
65
65
 
66
- async def test_ice_servers_provider_servers_land_in_configuration_first() -> None:
66
+ async def test_ice_servers_provider_servers_land_in_configuration_first(
67
+ monkeypatch: pytest.MonkeyPatch,
68
+ ) -> None:
69
+ monkeypatch.delenv("PHYSTACK_STUN_URL", raising=False)
67
70
  provider_calls: list[int] = []
68
71
 
69
72
  async def provider() -> list[dict[str, Any]]:
@@ -84,11 +87,17 @@ async def test_ice_servers_provider_servers_land_in_configuration_first() -> Non
84
87
  first_url = first_server_urls[0] if isinstance(first_server_urls, list) else first_server_urls
85
88
  assert first_url == TURN_URL
86
89
  assert configuration.iceServers[0].username == "user"
87
- # Static STUN entries still follow the provider list.
88
- assert any(url.startswith("stun:") for url in urls)
90
+ # No static third-party STUN follows the provider list: with
91
+ # PHYSTACK_STUN_URL unset the static fallback is empty (TECH-1521), so
92
+ # the provider-supplied servers stand alone.
93
+ assert len(configuration.iceServers) == 1
94
+
89
95
 
96
+ async def test_ice_servers_provider_failure_falls_back_to_static(
97
+ monkeypatch: pytest.MonkeyPatch,
98
+ ) -> None:
99
+ monkeypatch.delenv("PHYSTACK_STUN_URL", raising=False)
90
100
 
91
- async def test_ice_servers_provider_failure_falls_back_to_static() -> None:
92
101
  async def failing_provider() -> list[dict[str, Any]]:
93
102
  raise RuntimeError("phyhub unreachable")
94
103
 
@@ -99,10 +108,12 @@ async def test_ice_servers_provider_failure_falls_back_to_static() -> None:
99
108
 
100
109
  configuration = await handler._build_rtc_configuration(use_stun=True)
101
110
 
111
+ # TECH-1521: the static fallback carries no third-party STUN — with
112
+ # PHYSTACK_STUN_URL unset it is EMPTY (host candidates only), and the
113
+ # configuration must still be explicit (None would invoke aiortc's own
114
+ # Google STUN default).
102
115
  assert configuration is not None
103
- urls = flatten_urls(configuration)
104
- assert all(url.startswith("stun:") for url in urls)
105
- assert urls # static STUN fallback present
116
+ assert configuration.iceServers == []
106
117
 
107
118
 
108
119
  async def test_ice_servers_provider_timeout_falls_back_to_static(
@@ -121,10 +132,12 @@ async def test_ice_servers_provider_timeout_falls_back_to_static(
121
132
 
122
133
  configuration = await handler._build_rtc_configuration(use_stun=True)
123
134
 
135
+ # TECH-1521: the hung provider's TURN never lands, and the static
136
+ # fallback is empty (no third-party STUN) — explicit, not None.
124
137
  assert configuration is not None
125
138
  urls = flatten_urls(configuration)
126
139
  assert TURN_URL not in urls
127
- assert any(url.startswith("stun:") for url in urls)
140
+ assert urls == []
128
141
 
129
142
 
130
143
  # =============================================================================
@@ -0,0 +1,77 @@
1
+ """Guard tests for TECH-1521: no third-party STUN (Google or otherwise) may
2
+ ever be baked into the client. STUN comes from the phyhub ice_servers_provider
3
+ (our regional coturn) or from PHYSTACK_STUN_URL; with neither configured the
4
+ static fallback is empty (host candidates only).
5
+
6
+ Also pins the aiortc gotcha: RTCPeerConnection given NO configuration (None)
7
+ substitutes aiortc's own default — which is Google STUN — so every
8
+ configuration builder must return an explicit RTCConfiguration, never None.
9
+ """
10
+
11
+ import asyncio
12
+ import importlib
13
+ from pathlib import Path
14
+
15
+ from aiortc import RTCConfiguration
16
+
17
+ import phystack.hub_client.services.webrtc.media_stream as media_stream_module
18
+ from phystack.hub_client.services.webrtc.data_channel import DataChannelOptions
19
+ from phystack.hub_client.services.webrtc.media_responder import MediaStreamResponder
20
+ from phystack.hub_client.services.webrtc.media_stream import (
21
+ MediaStreamOptions,
22
+ build_ice_servers,
23
+ )
24
+
25
+ # Needles split so this file does not match its own scan.
26
+ GOOGLE_STUN_HOST = "l.google" + ".com"
27
+ GOOGLE_STUN_PORT = ":1930" + "2"
28
+
29
+
30
+ def test_default_stun_servers_empty_without_env(monkeypatch) -> None:
31
+ monkeypatch.delenv("PHYSTACK_STUN_URL", raising=False)
32
+ assert MediaStreamOptions().stun_servers == []
33
+ assert DataChannelOptions().stun_servers == []
34
+
35
+
36
+ def test_default_stun_servers_from_env(monkeypatch) -> None:
37
+ monkeypatch.setenv("PHYSTACK_STUN_URL", "stun:turn-qa.phystack.com:3478")
38
+ assert MediaStreamOptions().stun_servers == ["stun:turn-qa.phystack.com:3478"]
39
+ assert DataChannelOptions().stun_servers == ["stun:turn-qa.phystack.com:3478"]
40
+ for url in MediaStreamOptions().stun_servers:
41
+ assert "google" not in url
42
+ assert GOOGLE_STUN_PORT not in url
43
+
44
+
45
+ def test_build_ice_servers_defaults_carry_no_third_party_stun(monkeypatch) -> None:
46
+ monkeypatch.delenv("PHYSTACK_STUN_URL", raising=False)
47
+ monkeypatch.delenv("PHYSTACK_TURN_URL", raising=False)
48
+ servers = build_ice_servers(MediaStreamOptions())
49
+ assert servers == []
50
+
51
+
52
+ def test_responder_configuration_is_explicit_even_when_empty(monkeypatch) -> None:
53
+ # aiortc's None-configuration default is Google STUN; the builder must
54
+ # return an explicit (possibly empty) RTCConfiguration instead.
55
+ monkeypatch.delenv("PHYSTACK_STUN_URL", raising=False)
56
+ monkeypatch.delenv("PHYSTACK_TURN_URL", raising=False)
57
+ responder = MediaStreamResponder(
58
+ twin_id="twin-guard-test",
59
+ twin_messaging=None, # type: ignore[arg-type] # start() is never called
60
+ callback=lambda peer: None,
61
+ )
62
+ configuration = asyncio.run(responder._build_configuration())
63
+ assert isinstance(configuration, RTCConfiguration)
64
+ assert configuration.iceServers == []
65
+
66
+
67
+ def test_no_google_stun_literal_anywhere_in_source() -> None:
68
+ package_root = Path(importlib.import_module("phystack.hub_client").__file__).parent
69
+ scanned = 0
70
+ for source_path in package_root.rglob("*.py"):
71
+ content = source_path.read_text(encoding="utf-8", errors="ignore")
72
+ assert GOOGLE_STUN_HOST not in content, f"Google STUN host in {source_path}"
73
+ assert GOOGLE_STUN_PORT not in content, f"Google STUN port in {source_path}"
74
+ scanned += 1
75
+ assert scanned > 10 # sanity: the walk really covered the package
76
+ # media_stream must exist and stay the single source of the default.
77
+ assert hasattr(media_stream_module, "_get_default_stun_servers")