pywiim 2.3.3__tar.gz → 2.3.4__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 (86) hide show
  1. {pywiim-2.3.3/pywiim.egg-info → pywiim-2.3.4}/PKG-INFO +1 -1
  2. {pywiim-2.3.3 → pywiim-2.3.4}/pyproject.toml +1 -1
  3. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/__init__.py +1 -1
  4. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/statemgr.py +48 -6
  5. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/state.py +77 -86
  6. {pywiim-2.3.3 → pywiim-2.3.4/pywiim.egg-info}/PKG-INFO +1 -1
  7. {pywiim-2.3.3 → pywiim-2.3.4}/LICENSE +0 -0
  8. {pywiim-2.3.3 → pywiim-2.3.4}/README.md +0 -0
  9. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/__init__.py +0 -0
  10. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/audio_pro.py +0 -0
  11. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/audio_settings.py +0 -0
  12. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/base.py +0 -0
  13. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/bluetooth.py +0 -0
  14. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/constants.py +0 -0
  15. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/device.py +0 -0
  16. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/diagnostics.py +0 -0
  17. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/endpoints.py +0 -0
  18. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/eq.py +0 -0
  19. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/firmware.py +0 -0
  20. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/group.py +0 -0
  21. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/lms.py +0 -0
  22. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/loop_mode.py +0 -0
  23. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/misc.py +0 -0
  24. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/parser.py +0 -0
  25. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/peq.py +0 -0
  26. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/playback.py +0 -0
  27. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/preset.py +0 -0
  28. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/ssl.py +0 -0
  29. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/subwoofer.py +0 -0
  30. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/api/timer.py +0 -0
  31. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/backoff.py +0 -0
  32. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/capabilities.py +0 -0
  33. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/cli/__init__.py +0 -0
  34. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/cli/diagnostics.py +0 -0
  35. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/cli/discovery_cli.py +0 -0
  36. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/cli/group_test_cli.py +0 -0
  37. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/cli/join_test_cli.py +0 -0
  38. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/cli/monitor_cli.py +0 -0
  39. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/cli/verify_cli.py +0 -0
  40. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/client.py +0 -0
  41. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/device_capabilities.py +0 -0
  42. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/discovery.py +0 -0
  43. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/exceptions.py +0 -0
  44. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/group.py +0 -0
  45. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/group_helpers.py +0 -0
  46. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/mcp/__init__.py +0 -0
  47. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/mcp/__main__.py +0 -0
  48. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/mcp/config.example.json +0 -0
  49. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/mcp/config.py +0 -0
  50. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/mcp/context.py +0 -0
  51. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/mcp/server.py +0 -0
  52. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/metadata.py +0 -0
  53. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/model_names.py +0 -0
  54. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/models.py +0 -0
  55. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/normalize.py +0 -0
  56. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/__init__.py +0 -0
  57. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/audio.py +0 -0
  58. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/base.py +0 -0
  59. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/bluetooth.py +0 -0
  60. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/coverart.py +0 -0
  61. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/debounce.py +0 -0
  62. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/diagnostics.py +0 -0
  63. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/groupops.py +0 -0
  64. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/media.py +0 -0
  65. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/playback.py +0 -0
  66. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/properties.py +0 -0
  67. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/source_capabilities.py +0 -0
  68. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/stream.py +0 -0
  69. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/stream_enricher.py +0 -0
  70. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/player/volume.py +0 -0
  71. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/polling.py +0 -0
  72. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/profiles.py +0 -0
  73. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/py.typed +0 -0
  74. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/role.py +0 -0
  75. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/upnp/__init__.py +0 -0
  76. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/upnp/client.py +0 -0
  77. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/upnp/eventer.py +0 -0
  78. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/upnp/health.py +0 -0
  79. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/upnp/metadata.py +0 -0
  80. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim/upnp/playqueue.py +0 -0
  81. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim.egg-info/SOURCES.txt +0 -0
  82. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim.egg-info/dependency_links.txt +0 -0
  83. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim.egg-info/entry_points.txt +0 -0
  84. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim.egg-info/requires.txt +0 -0
  85. {pywiim-2.3.3 → pywiim-2.3.4}/pywiim.egg-info/top_level.txt +0 -0
  86. {pywiim-2.3.3 → pywiim-2.3.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.3.3
3
+ Version: 2.3.4
4
4
  Summary: Python library for WiiM/LinkPlay device communication
5
5
  Author-email: Michael Cumming <mjcumming@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pywiim"
7
- version = "2.3.3"
7
+ version = "2.3.4"
8
8
  description = "Python library for WiiM/LinkPlay device communication"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -102,7 +102,7 @@ from .profiles import (
102
102
  from .role import RoleDetectionResult, detect_role
103
103
  from .state import GroupStateSynchronizer, StateSynchronizer
104
104
 
105
- __version__ = "2.3.3"
105
+ __version__ = "2.3.4"
106
106
  __all__ = [
107
107
  # Main client
108
108
  "WiiMClient",
@@ -37,6 +37,19 @@ _LOGGER = logging.getLogger(__name__)
37
37
  # UPnP retry cooldown - wait this many seconds between failed creation attempts
38
38
  UPNP_RETRY_COOLDOWN = 60.0
39
39
 
40
+ # Sources where play_url() filename fallback is the intended title.
41
+ # WiiM reports URL playback as custompushurl (display name "URL Stream").
42
+ _URL_PLAYBACK_SOURCES = frozenset({"wifi", "network", "ethernet", "usb", "udisk", "http", "custompushurl"})
43
+
44
+
45
+ def _is_url_playback_source(source: Any) -> bool:
46
+ """Return True for URL/network playback sources (keep play_url filename)."""
47
+ if source is None:
48
+ return False
49
+ key = str(source).strip().lower().replace("-", "_").replace(" ", "_")
50
+ return key in _URL_PLAYBACK_SOURCES or key in {"wi_fi"}
51
+
52
+
40
53
  # Standard sources that shouldn't be cleared when checking for multiroom/master names
41
54
  STANDARD_SOURCES = {
42
55
  "spotify",
@@ -340,9 +353,11 @@ class StateManager:
340
353
  async def _refresh_core_status(self) -> PlayerStatus:
341
354
  """Fetch core player status and update state synchronizer.
342
355
 
343
- For slaves in multi-room groups, we use getStatusEx (device status) instead of
344
- getPlayerStatusEx. Playback state (track, position, etc.) comes from the master
345
- via propagation. We only need volume/mute/group from slaves.
356
+ For slaves in multi-room groups, we poll the capability-configured status
357
+ endpoint for volume/mute/group only. Playback state (track, position, etc.)
358
+ comes from the master via propagation. Volume and mute are written to the
359
+ state synchronizer so ``Player.volume_level`` / ``is_muted`` reflect device
360
+ changes (physical buttons, app) on the next poll.
346
361
 
347
362
  Returns:
348
363
  PlayerStatus model from device.
@@ -380,12 +395,18 @@ class StateManager:
380
395
  status = PlayerStatus.model_construct() # Empty status, fields populated below
381
396
 
382
397
  # Update volume/mute/group from device status (these are slave-specific)
398
+ observed_volume = False
399
+ observed_mute = False
383
400
  if "volume" in status_dict:
384
401
  vol = status_dict.get("volume")
385
402
  if vol is not None:
386
403
  status.volume = int(vol) if not isinstance(vol, int) else vol
404
+ observed_volume = True
387
405
  if "mute" in status_dict:
388
- status.mute = status_dict.get("mute")
406
+ mute_val = status_dict.get("mute")
407
+ if mute_val is not None:
408
+ status.mute = mute_val
409
+ observed_mute = True
389
410
  if "group" in status_dict:
390
411
  status.group = status_dict.get("group")
391
412
 
@@ -402,12 +423,30 @@ class StateManager:
402
423
  mute_from_upnp = await self.player._upnp_client.get_mute()
403
424
  if volume_from_upnp is not None:
404
425
  status.volume = volume_from_upnp
426
+ observed_volume = True
405
427
  if mute_from_upnp is not None:
406
428
  status.mute = mute_from_upnp
429
+ observed_mute = True
407
430
  except Exception as err:
408
431
  _LOGGER.debug("UPnP GetVolume failed for slave %s: %s", self.player.host, err)
409
432
 
410
433
  self.player._status_model = status
434
+
435
+ # volume_level / is_muted read the synchronizer first. Physical-button
436
+ # and app changes on a slave only arrive via this poll (HA does not
437
+ # subscribe to UPnP events), so we must push slave-local volume/mute
438
+ # without playback/metadata (those stay master-propagated).
439
+ # Only stamp fields observed this cycle so a missing reading cannot
440
+ # refresh a stale cache timestamp over a newer UPnP event.
441
+ # See: https://github.com/mjcumming/wiim/issues/269
442
+ http_update: dict[str, Any] = {}
443
+ if observed_volume and status.volume is not None:
444
+ http_update["volume"] = status.volume
445
+ if observed_mute and status.mute is not None:
446
+ http_update["muted"] = status.mute
447
+ if http_update:
448
+ self.player._state_synchronizer.update_from_http(http_update)
449
+
411
450
  self.player._last_refresh = time.time()
412
451
  self.player._available = True
413
452
  return status
@@ -670,8 +709,11 @@ class StateManager:
670
709
  self._last_source = current_source
671
710
  elif source_changed:
672
711
  self._last_source = current_source
673
- # play_url() filename fallback must not survive a source switch (#263)
674
- self.player._last_played_url = None
712
+ # Filename fallback is for URL/network playback. Keep it when
713
+ # switching *to* wifi/network (play_url just changed the source).
714
+ # Clear it for physical inputs and other sources (#263).
715
+ if not _is_url_playback_source(current_source):
716
+ self.player._last_played_url = None
675
717
  if status:
676
718
  status.title = None
677
719
  status.artist = None
@@ -347,13 +347,6 @@ class StateSynchronizer:
347
347
  timestamp=ts,
348
348
  )
349
349
 
350
- if "duration" in data and not self._is_leftover_after_source_change("duration", data.get("duration")):
351
- self._http_state["duration"] = TimestampedField(
352
- value=data.get("duration"),
353
- source=source,
354
- timestamp=ts,
355
- )
356
-
357
350
  # Extract volume and mute
358
351
  # Only update if value is not None (preserve existing values when API doesn't return volume)
359
352
  # This prevents clearing volume when some devices (e.g., Audio Pro) don't return volume
@@ -372,40 +365,14 @@ class StateSynchronizer:
372
365
  timestamp=ts,
373
366
  )
374
367
 
375
- # Extract source — clear track metadata when the input actually changes
376
- source_changed = False
377
- if "source" in data:
378
- new_source = data.get("source")
379
- old_field = self._merged_state.source
380
- old_source = old_field.value if old_field is not None else None
381
- source_changed = self._sources_differ(old_source, new_source)
382
- if source_changed:
383
- self._remember_stale_metadata(data)
384
- self._clear_track_metadata()
385
- self._http_state["source"] = TimestampedField(
386
- value=new_source,
387
- source=source,
388
- timestamp=ts,
389
- )
390
-
391
- self._release_stale_guard_if_fresh_track(data)
392
-
393
- # Extract metadata (preserve if playing)
394
- if not source_changed and (force_metadata_update or not self._should_clear_metadata()):
395
- for field_name in ["title", "artist", "album", "image_url"]:
396
- if field_name in data:
397
- value = data.get(field_name)
398
- if self._is_leftover_after_source_change(field_name, value):
399
- continue
400
- # Always update if field is present in data (even if None)
401
- # This ensures metadata gets populated when available
402
- # If value is None/empty, we still update to track that HTTP doesn't have it
403
- # (but existing metadata from other sources will be preserved via merge)
404
- self._http_state[field_name] = TimestampedField(
405
- value=value,
406
- source=source,
407
- timestamp=ts,
408
- )
368
+ # Source first so leftover-guard is set before duration/title are applied.
369
+ self._apply_source_and_track_fields(
370
+ data,
371
+ self._http_state,
372
+ source,
373
+ ts,
374
+ force_metadata_update=force_metadata_update,
375
+ )
409
376
 
410
377
  self._merged_state.http_last_update = ts
411
378
  self._merge_state()
@@ -445,13 +412,6 @@ class StateSynchronizer:
445
412
  timestamp=ts,
446
413
  )
447
414
 
448
- if "duration" in data and not self._is_leftover_after_source_change("duration", data.get("duration")):
449
- self._upnp_state["duration"] = TimestampedField(
450
- value=data.get("duration"),
451
- source="upnp",
452
- timestamp=ts,
453
- )
454
-
455
415
  # Extract volume and mute
456
416
  if "volume" in data:
457
417
  self._upnp_state["volume"] = TimestampedField(
@@ -467,40 +427,13 @@ class StateSynchronizer:
467
427
  timestamp=ts,
468
428
  )
469
429
 
470
- # Extract source — clear track metadata when the input actually changes
471
- source_changed = False
472
- if "source" in data:
473
- new_source = data.get("source")
474
- old_field = self._merged_state.source
475
- old_source = old_field.value if old_field is not None else None
476
- source_changed = self._sources_differ(old_source, new_source)
477
- if source_changed:
478
- self._remember_stale_metadata(data)
479
- self._clear_track_metadata()
480
- self._upnp_state["source"] = TimestampedField(
481
- value=new_source,
482
- source="upnp",
483
- timestamp=ts,
484
- )
485
-
486
- self._release_stale_guard_if_fresh_track(data)
487
-
488
- # Extract metadata (preserve if playing)
489
- if not source_changed and (force_metadata_update or not self._should_clear_metadata()):
490
- for field_name in ["title", "artist", "album", "image_url"]:
491
- if field_name in data:
492
- value = data.get(field_name)
493
- if self._is_leftover_after_source_change(field_name, value):
494
- continue
495
- # Always update if field is present in data (even if None)
496
- # This ensures metadata gets populated when available
497
- # If value is None/empty, we still update to track that UPnP doesn't have it
498
- # (but existing metadata from other sources will be preserved via merge)
499
- self._upnp_state[field_name] = TimestampedField(
500
- value=value,
501
- source="upnp",
502
- timestamp=ts,
503
- )
430
+ self._apply_source_and_track_fields(
431
+ data,
432
+ self._upnp_state,
433
+ "upnp",
434
+ ts,
435
+ force_metadata_update=force_metadata_update,
436
+ )
504
437
 
505
438
  self._merged_state.upnp_last_update = ts
506
439
  self._merge_state()
@@ -914,14 +847,72 @@ class StateSynchronizer:
914
847
  new_n = str(new).strip().lower().replace("_", "-").replace(" ", "-")
915
848
  return bool(old_n and new_n and old_n != new_n)
916
849
 
917
- def _remember_stale_metadata(self, data: dict[str, Any]) -> None:
918
- """Snapshot leftover firmware metadata so later polls cannot re-apply it."""
850
+ def _apply_source_and_track_fields(
851
+ self,
852
+ data: dict[str, Any],
853
+ dest: dict[str, TimestampedField],
854
+ source: str,
855
+ ts: float,
856
+ *,
857
+ force_metadata_update: bool,
858
+ ) -> None:
859
+ """Apply source, then duration/title so leftover-guard is already set.
860
+
861
+ On a source change, snapshot only the *pre-switch* merged track and then
862
+ still accept incoming fields that are not that leftover. Skipping the
863
+ transition poll (and snapshotting incoming) hid Spotify title/artist
864
+ until the next track (wiim #263).
865
+ """
866
+ source_changed = False
867
+ if "source" in data:
868
+ new_source = data.get("source")
869
+ old_field = self._merged_state.source
870
+ old_source = old_field.value if old_field is not None else None
871
+ source_changed = self._sources_differ(old_source, new_source)
872
+ if source_changed:
873
+ self._remember_stale_metadata()
874
+ self._clear_track_metadata()
875
+ dest["source"] = TimestampedField(
876
+ value=new_source,
877
+ source=source,
878
+ timestamp=ts,
879
+ )
880
+
881
+ self._release_stale_guard_if_fresh_track(data)
882
+
883
+ if "duration" in data and not self._is_leftover_after_source_change("duration", data.get("duration")):
884
+ dest["duration"] = TimestampedField(
885
+ value=data.get("duration"),
886
+ source=source,
887
+ timestamp=ts,
888
+ )
889
+
890
+ apply_metadata = force_metadata_update or source_changed or not self._should_clear_metadata()
891
+ if not apply_metadata:
892
+ return
893
+ for field_name in ("title", "artist", "album", "image_url"):
894
+ if field_name not in data:
895
+ continue
896
+ value = data.get(field_name)
897
+ if self._is_leftover_after_source_change(field_name, value):
898
+ continue
899
+ dest[field_name] = TimestampedField(
900
+ value=value,
901
+ source=source,
902
+ timestamp=ts,
903
+ )
904
+
905
+ def _remember_stale_metadata(self) -> None:
906
+ """Snapshot pre-switch merged metadata so later polls cannot re-apply it.
907
+
908
+ Incoming values on the same poll may already be the new track
909
+ (network stream → Spotify) and must not be added to the leftover set.
910
+ """
919
911
  stale: dict[str, set[Any]] = {}
920
912
  for field_name in ("title", "artist", "album", "image_url", "duration"):
921
- incoming = data.get(field_name)
922
913
  merged_field = getattr(self._merged_state, field_name, None)
923
914
  merged_val = merged_field.value if merged_field is not None else None
924
- values = {v for v in (incoming, merged_val) if v not in (None, "")}
915
+ values = {v for v in (merged_val,) if v not in (None, "")}
925
916
  stale[field_name] = values
926
917
  self._stale_metadata_after_source = stale
927
918
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.3.3
3
+ Version: 2.3.4
4
4
  Summary: Python library for WiiM/LinkPlay device communication
5
5
  Author-email: Michael Cumming <mjcumming@gmail.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes