ucapi-framework 1.9.1__tar.gz → 1.9.3__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 (50) hide show
  1. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/PKG-INFO +1 -1
  2. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/pyproject.toml +1 -1
  3. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_discovery.py +67 -8
  4. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/__init__.py +2 -0
  5. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/discovery.py +24 -21
  6. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/helpers.py +163 -0
  7. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework.egg-info/PKG-INFO +1 -1
  8. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/README.md +0 -0
  9. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/setup.cfg +0 -0
  10. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_button_entity.py +0 -0
  11. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_climate_entity.py +0 -0
  12. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_config.py +0 -0
  13. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_cover_entity.py +0 -0
  14. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_device.py +0 -0
  15. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_driver.py +0 -0
  16. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_entity.py +0 -0
  17. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_factory_pattern.py +0 -0
  18. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_helpers.py +0 -0
  19. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_ir_emitter_entity.py +0 -0
  20. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_light_entity.py +0 -0
  21. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_media_player_entity.py +0 -0
  22. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_migration.py +0 -0
  23. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_remote_entity.py +0 -0
  24. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_select_entity.py +0 -0
  25. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_sensor_entity.py +0 -0
  26. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_setup.py +0 -0
  27. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_switch_entity.py +0 -0
  28. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/tests/test_voice_assistant_entity.py +0 -0
  29. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/config.py +0 -0
  30. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/device.py +0 -0
  31. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/driver.py +0 -0
  32. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/__init__.py +0 -0
  33. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/button.py +0 -0
  34. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/climate.py +0 -0
  35. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/cover.py +0 -0
  36. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/ir_emitter.py +0 -0
  37. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/light.py +0 -0
  38. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/media_player.py +0 -0
  39. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/remote.py +0 -0
  40. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/select.py +0 -0
  41. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/sensor.py +0 -0
  42. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/switch.py +0 -0
  43. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entities/voice_assistant.py +0 -0
  44. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/entity.py +0 -0
  45. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/migration.py +0 -0
  46. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework/setup.py +0 -0
  47. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework.egg-info/SOURCES.txt +0 -0
  48. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework.egg-info/dependency_links.txt +0 -0
  49. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework.egg-info/requires.txt +0 -0
  50. {ucapi_framework-1.9.1 → ucapi_framework-1.9.3}/ucapi_framework.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi-framework
3
- Version: 1.9.1
3
+ Version: 1.9.3
4
4
  Summary: ucapi framework that provides core functionality for building integrations.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ucapi-framework"
3
- version = "1.9.1"
3
+ version = "1.9.3"
4
4
  description = "ucapi framework that provides core functionality for building integrations."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -451,28 +451,87 @@ class TestMDNSDiscovery:
451
451
 
452
452
  @pytest.mark.asyncio
453
453
  async def test_discover_with_zeroconf(self):
454
- """Test mDNS discovery with mocked zeroconf."""
454
+ """Test mDNS discovery with mocked zeroconf async classes."""
455
455
  mock_service_info = Mock()
456
456
  mock_service_info.name = "test-device._test._tcp.local."
457
457
  mock_service_info.server = "Test Device"
458
458
  mock_service_info.addresses = [b"\xc0\xa8\x01\x64"] # 192.168.1.100 as bytes
459
459
  mock_service_info.port = 8080
460
460
 
461
- mock_zeroconf = Mock()
462
- mock_service_browser = Mock()
461
+ # ServiceListener must be a real class so Listener can inherit from it
462
+ class MockServiceListener:
463
+ pass
464
+
465
+ # Track the Listener instance created inside discover()
466
+ captured_listener: dict = {}
467
+
468
+ # Mock AsyncZeroconf
469
+ mock_aiozc = AsyncMock()
470
+ mock_aiozc.zeroconf = Mock()
471
+ mock_aiozc.async_get_service_info = AsyncMock(return_value=mock_service_info)
472
+ mock_aiozc.async_close = AsyncMock()
473
+
474
+ # Mock AsyncServiceBrowser – capture the listener so we can call add_service
475
+ def mock_browser_init(zc, service_type, listener):
476
+ captured_listener["obj"] = listener
477
+ browser = Mock()
478
+ browser.async_cancel = AsyncMock()
479
+ return browser
463
480
 
464
481
  mock_zeroconf_module = Mock()
465
- mock_zeroconf_module.Zeroconf.return_value = mock_zeroconf
466
- mock_zeroconf_module.ServiceBrowser.return_value = mock_service_browser
482
+ mock_zeroconf_module.ServiceListener = MockServiceListener
483
+
484
+ mock_asyncio_module = Mock()
485
+ mock_asyncio_module.AsyncZeroconf = Mock(return_value=mock_aiozc)
486
+ mock_asyncio_module.AsyncServiceBrowser = Mock(side_effect=mock_browser_init)
487
+
488
+ with (
489
+ patch.dict(
490
+ "sys.modules",
491
+ {
492
+ "zeroconf": mock_zeroconf_module,
493
+ "zeroconf.asyncio": mock_asyncio_module,
494
+ },
495
+ ),
496
+ patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
497
+ ):
498
+
499
+ async def trigger_discovery(*args, **kwargs):
500
+ listener = captured_listener.get("obj")
501
+ if listener:
502
+ listener.add_service(
503
+ mock_aiozc.zeroconf,
504
+ "_test._tcp.local.",
505
+ "test-device._test._tcp.local.",
506
+ )
507
+
508
+ mock_sleep.side_effect = trigger_discovery
467
509
 
468
- with patch.dict("sys.modules", {"zeroconf": mock_zeroconf_module}):
469
510
  discovery = ConcreteMDNSDiscovery(
470
511
  service_type="_test._tcp.local.", timeout=1
471
512
  )
472
513
  devices = await discovery.discover()
473
514
 
474
- # Due to asyncio sleep and mocking complexity, this is more of a structure test
475
- assert isinstance(devices, list)
515
+ assert len(devices) == 1
516
+ assert devices[0].identifier == "test-device._test._tcp.local."
517
+ assert devices[0].name == "Test Device"
518
+ mock_aiozc.async_get_service_info.assert_called_once_with(
519
+ "_test._tcp.local.", "test-device._test._tcp.local."
520
+ )
521
+
522
+ @pytest.mark.asyncio
523
+ async def test_discover_handles_import_error(self):
524
+ """Test that discover handles missing zeroconf gracefully."""
525
+ discovery = ConcreteMDNSDiscovery(service_type="_test._tcp.local.", timeout=1)
526
+
527
+ def mock_import(name, *args, **kwargs):
528
+ if name in ("zeroconf", "zeroconf.asyncio"):
529
+ raise ImportError(f"No module named '{name}'")
530
+ return __import__(name, *args, **kwargs)
531
+
532
+ with patch("builtins.__import__", side_effect=mock_import):
533
+ devices = await discovery.discover()
534
+ assert devices == []
476
535
 
477
536
 
478
537
  class TestNetworkScanDiscovery:
@@ -48,6 +48,7 @@ from .migration import (
48
48
  )
49
49
  from .helpers import (
50
50
  find_orphaned_entities,
51
+ find_unused_activity_entities,
51
52
  EntityAttributes,
52
53
  ButtonAttributes,
53
54
  ClimateAttributes,
@@ -126,6 +127,7 @@ __all__ = [
126
127
  "SensorEntity",
127
128
  "SwitchEntity",
128
129
  "VoiceAssistantEntity",
130
+ "find_unused_activity_entities",
129
131
  ]
130
132
 
131
133
  __version__ = "1.9.1"
@@ -34,6 +34,7 @@ for integrations that don't use them. Install only what you need:
34
34
  :license: Mozilla Public License Version 2.0, see LICENSE for more details.
35
35
  """
36
36
 
37
+ import asyncio
37
38
  import logging
38
39
  from abc import ABC, abstractmethod
39
40
  from dataclasses import dataclass
@@ -356,40 +357,44 @@ class MDNSDiscovery(BaseDiscovery):
356
357
 
357
358
  try:
358
359
  try:
359
- from zeroconf import ServiceBrowser, ServiceListener, Zeroconf # type: ignore[import-not-found]
360
+ from zeroconf import ServiceListener # type: ignore[import-not-found]
361
+ from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf
360
362
  except ImportError as err:
361
363
  raise ImportError(
362
364
  "zeroconf package is required for mDNS discovery. "
363
365
  "Install it with: pip install zeroconf"
364
366
  ) from err
365
367
 
366
- import asyncio
367
-
368
- zeroconf = Zeroconf()
369
- discovered = []
368
+ discovered_names: list[tuple[str, str]] = []
370
369
 
371
370
  class Listener(ServiceListener):
372
- def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
373
- info = zc.get_service_info(type_, name)
374
- if info:
375
- discovered.append(info)
371
+ """Listener for mDNS service discovery events."""
376
372
 
377
- def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
378
- pass
373
+ def add_service(self, zc, type_: str, name: str) -> None:
374
+ discovered_names.append((type_, name))
379
375
 
380
- def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
376
+ def remove_service(self, zc, type_: str, name: str) -> None:
381
377
  pass
382
378
 
383
- browser = ServiceBrowser(zeroconf, self.service_type, Listener())
384
-
385
- # Wait for discovery timeout
386
- await asyncio.sleep(self.timeout)
379
+ def update_service(self, zc, type_: str, name: str) -> None:
380
+ pass
387
381
 
388
- browser.cancel()
389
- zeroconf.close()
382
+ aiozc = AsyncZeroconf()
383
+ try:
384
+ browser = AsyncServiceBrowser(
385
+ aiozc.zeroconf, self.service_type, Listener()
386
+ )
387
+ await asyncio.sleep(self.timeout)
388
+ await browser.async_cancel()
389
+ discovered = [
390
+ info
391
+ for type_, name in discovered_names
392
+ if (info := await aiozc.async_get_service_info(type_, name))
393
+ ]
394
+ finally:
395
+ await aiozc.async_close()
390
396
 
391
397
  self._discovered_devices.clear()
392
-
393
398
  for service_info in discovered:
394
399
  device = self.parse_mdns_service(service_info)
395
400
  if device:
@@ -399,10 +404,8 @@ class MDNSDiscovery(BaseDiscovery):
399
404
  "mDNS discovery complete: found %d device(s)",
400
405
  len(self._discovered_devices),
401
406
  )
402
-
403
407
  except Exception as err: # pylint: disable=broad-exception-caught
404
408
  _LOG.error("mDNS discovery error: %s", err)
405
-
406
409
  return self._discovered_devices
407
410
 
408
411
  @abstractmethod
@@ -294,3 +294,166 @@ async def find_orphaned_entities(
294
294
  except Exception as err: # pylint: disable=broad-except
295
295
  _LOG.error("Unexpected error while scanning for orphaned entities: %s", err)
296
296
  return orphaned_entities
297
+
298
+
299
+ def _extract_used_entity_ids(activity: dict[str, Any]) -> set[str]:
300
+ """
301
+ Extract all entity IDs that are actively used in an activity's sequences,
302
+ button_mapping, and user_interface.
303
+
304
+ :param activity: Full activity dict from GET /api/activities/{id}
305
+ :return: Set of entity_id strings that are referenced
306
+ """
307
+ used: set[str] = set()
308
+ options = activity.get("options", {})
309
+
310
+ # sequences: on/off lists of steps; each step may have command.entity_id
311
+ sequences = options.get("sequences", {})
312
+ for steps in sequences.values():
313
+ if not isinstance(steps, list):
314
+ continue
315
+ for step in steps:
316
+ cmd = step.get("command", {})
317
+ if isinstance(cmd, dict) and cmd.get("entity_id"):
318
+ used.add(cmd["entity_id"])
319
+
320
+ # button_mapping: list of {short_press, long_press} each with entity_id
321
+ for mapping in options.get("button_mapping", []):
322
+ for press_key in ("short_press", "long_press"):
323
+ press = mapping.get(press_key, {})
324
+ if isinstance(press, dict) and press.get("entity_id"):
325
+ used.add(press["entity_id"])
326
+
327
+ # user_interface: pages → items; entity IDs hide in several fields
328
+ ui = options.get("user_interface", {})
329
+ for page in ui.get("pages", []):
330
+ for item in page.get("items", []):
331
+ # Direct command entity
332
+ cmd = item.get("command", {})
333
+ if isinstance(cmd, dict) and cmd.get("entity_id"):
334
+ used.add(cmd["entity_id"])
335
+ # media_player_id
336
+ if item.get("media_player_id"):
337
+ used.add(item["media_player_id"])
338
+ # sensor_id
339
+ sensor = item.get("sensor", {})
340
+ if isinstance(sensor, dict) and sensor.get("sensor_id"):
341
+ used.add(sensor["sensor_id"])
342
+ # select_id
343
+ select = item.get("select", {})
344
+ if isinstance(select, dict) and select.get("select_id"):
345
+ used.add(select["select_id"])
346
+
347
+ return used
348
+
349
+
350
+ async def find_unused_activity_entities(
351
+ remote_url: str,
352
+ pin: str | None = None,
353
+ api_key: str | None = None,
354
+ ) -> list[dict[str, Any]]:
355
+ """
356
+ Find entities included in activities that are never actually used.
357
+
358
+ An entity is "unused" when it appears in ``included_entities`` but has no
359
+ reference in sequences, button_mapping, or user_interface (commands, media
360
+ player widgets, sensor widgets, or select widgets).
361
+
362
+ Authentication can be done via PIN (Basic Auth) or API key (Bearer token).
363
+ One of ``pin`` or ``api_key`` must be provided.
364
+
365
+ :param remote_url: The Remote's base URL (e.g., "http://192.168.1.100")
366
+ :param pin: Remote's web-configurator PIN for Basic Auth
367
+ :param api_key: Remote's API key for Bearer token authentication
368
+ :return: List of dicts with activity context and the unused entity info
369
+ :raises ValueError: If neither pin nor api_key is provided
370
+ """
371
+ if not pin and not api_key:
372
+ raise ValueError("Either pin or api_key must be provided for authentication")
373
+
374
+ _LOG.info("Scanning for unused activity entities on Remote at %s", remote_url)
375
+
376
+ headers = {"Content-Type": "application/json"}
377
+ if api_key:
378
+ headers["Authorization"] = f"Bearer {api_key}"
379
+
380
+ auth = None
381
+ if pin and not api_key:
382
+ auth = aiohttp.BasicAuth(login="web-configurator", password=pin)
383
+
384
+ unused: list[dict[str, Any]] = []
385
+
386
+ try:
387
+ async with aiohttp.ClientSession() as session:
388
+ activities_url = f"{remote_url}/api/activities?limit=100"
389
+ async with session.get(
390
+ activities_url,
391
+ headers=headers,
392
+ auth=auth,
393
+ timeout=aiohttp.ClientTimeout(total=30),
394
+ ) as resp:
395
+ if resp.status != 200:
396
+ _LOG.error("Failed to fetch activities: HTTP %d", resp.status)
397
+ return unused
398
+ activities_list = await resp.json()
399
+ _LOG.info(
400
+ "Found %d activities to scan for unused entities",
401
+ len(activities_list),
402
+ )
403
+
404
+ for activity_summary in activities_list:
405
+ activity_id = activity_summary.get("entity_id")
406
+ if not activity_id:
407
+ continue
408
+
409
+ activity_url = f"{remote_url}/api/activities/{activity_id}"
410
+ async with session.get(
411
+ activity_url,
412
+ headers=headers,
413
+ auth=auth,
414
+ timeout=aiohttp.ClientTimeout(total=30),
415
+ ) as resp:
416
+ if resp.status != 200:
417
+ _LOG.warning(
418
+ "Failed to fetch activity %s: HTTP %d",
419
+ activity_id,
420
+ resp.status,
421
+ )
422
+ continue
423
+ activity = await resp.json()
424
+
425
+ activity_name = activity_summary.get("name") or activity.get("name", {})
426
+ options = activity.get("options", {})
427
+ included_entities = options.get("included_entities", [])
428
+
429
+ if not included_entities:
430
+ continue
431
+
432
+ included_ids = {
433
+ e["entity_id"] for e in included_entities if e.get("entity_id")
434
+ }
435
+ used_ids = _extract_used_entity_ids(activity)
436
+ truly_unused = included_ids - used_ids
437
+
438
+ for entity in included_entities:
439
+ eid = entity.get("entity_id")
440
+ if eid in truly_unused:
441
+ record = {
442
+ k: v
443
+ for k, v in entity.items()
444
+ if k not in ("entity_commands", "simple_commands")
445
+ }
446
+ record["activity_id"] = activity_id
447
+ record["activity_name"] = activity_name
448
+ unused.append(record)
449
+ _LOG.debug("Unused entity %s in activity %s", eid, activity_id)
450
+
451
+ _LOG.info("Found %d unused activity entities", len(unused))
452
+ return unused
453
+
454
+ except aiohttp.ClientError as err:
455
+ _LOG.error("Network error while scanning for unused entities: %s", err)
456
+ return unused
457
+ except Exception as err: # pylint: disable=broad-except
458
+ _LOG.error("Unexpected error while scanning for unused entities: %s", err)
459
+ return unused
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi-framework
3
- Version: 1.9.1
3
+ Version: 1.9.3
4
4
  Summary: ucapi framework that provides core functionality for building integrations.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown