diff --git a/apps/predbat/lattice_ge_cloud_fragment.py b/apps/predbat/lattice_ge_cloud_fragment.py new file mode 100644 index 000000000..aa4b2fc9b --- /dev/null +++ b/apps/predbat/lattice_ge_cloud_fragment.py @@ -0,0 +1,455 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System - GE Cloud Lattice fragment adapter +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +"""Pure, default-off GivEnergy Cloud fragment publisher. + +The live ``GECloudDirect`` component owns API discovery and polling. This +module deliberately does not import, register, or mutate that component. A +future gated seam may pass explicit discovery/device snapshots here after a +successful cloud read. + +Every accepted discovery version, device change, liveness change, or removal +publishes a new durable generation through the common fragment-adapter +surface. Published aliases are REFERENCE-only: this tranche exposes identity +and provider-local topology for composition, but grants no materialization-ready +PRIMARY/CONTROL roles and publishes no PredBat configuration or control write. +""" + +# cspell:ignore autoconfig + +import threading +from dataclasses import dataclass +from typing import Optional + +from lattice_autoconfig import ( + AliasRole, + ProviderAlias, + ProviderHealth, + ProviderIdentityAlias, + ProviderSnapshot, + _plain, +) +from lattice_fragment_adapters import ( + DurableFragmentAdapter, + FragmentAdapterConflict, + FragmentAdapterReadError, + FragmentAdapterRemoved, +) + + +_HEALTH_UNCHANGED = object() +_DEVICE_KINDS = frozenset( + ( + "battery-inverter", + "ems", + "ev-charger", + "gateway", + "inverter", + "pv-inverter", + ) +) +_TOPOLOGY_KINDS = { + "battery-inverter": "inverter", + "ems": "energy-management-system", + "ev-charger": "ev-charger", + "gateway": "gateway", + "inverter": "inverter", + "pv-inverter": "inverter", +} + + +def _required_text(value, name): + """Return one normalized non-empty text field.""" + if not isinstance(value, str) or not value.strip(): + raise ValueError("{} must be a non-empty string".format(name)) + return value.strip() + + +def _optional_text(value, name): + """Return one normalized optional text field.""" + if value is None: + return None + return _required_text(value, name) + + +def _optional_identifier(value, name): + """Normalize an optional cloud identifier without accepting booleans.""" + if value is None: + return None + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return _required_text(value, name) + + +def _discovery_version(value): + """Validate one provider-owned monotonic discovery version.""" + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError("discovery_version must be a non-negative integer") + return value + + +def _provider_health(value): + """Normalize explicit integration liveness into compiler health.""" + if isinstance(value, ProviderHealth): + return value + if value is True: + return ProviderHealth.HEALTHY + if value is False: + return ProviderHealth.OFFLINE + if value is None: + return ProviderHealth.DEGRADED + raise ValueError( + "health must be ProviderHealth, True, False, or None", + ) + + +@dataclass(frozen=True) +class GECloudDeviceSnapshot: + """One explicit provider-local device discovered by GivEnergy Cloud.""" + + serial: str + kind: str = "inverter" + online: Optional[bool] = None + model: Optional[str] = None + site_id: Optional[object] = None + uuid: Optional[str] = None + + def __post_init__(self): + """Normalize stable identities and reject ambiguous device state.""" + serial = _required_text(self.serial, "serial").upper() + kind = _required_text(self.kind, "kind").lower().replace("_", "-") + if kind not in _DEVICE_KINDS: + raise ValueError( + "kind must be one of {}".format( + ", ".join(sorted(_DEVICE_KINDS)), + ) + ) + if self.online is not None and not isinstance(self.online, bool): + raise ValueError("online must be True, False, or None") + object.__setattr__(self, "serial", serial) + object.__setattr__(self, "kind", kind) + object.__setattr__(self, "model", _optional_text(self.model, "model")) + object.__setattr__( + self, + "site_id", + _optional_identifier(self.site_id, "site_id"), + ) + object.__setattr__(self, "uuid", _optional_text(self.uuid, "uuid")) + + @property + def node_id(self): + """Return the deterministic provider-local node identity.""" + return "ge-cloud:{}".format(self.serial) + + +def _normalize_devices(devices): + """Freeze, sort, and collision-check explicit device snapshots.""" + try: + devices = tuple(devices) + except TypeError as exc: + raise ValueError("devices must be an iterable of GECloudDeviceSnapshot") from exc + if any(not isinstance(device, GECloudDeviceSnapshot) for device in devices): + raise ValueError("devices must contain only GECloudDeviceSnapshot values") + if not devices: + raise ValueError("devices must contain at least one GECloudDeviceSnapshot") + + by_serial = {} + uuid_owners = {} + for device in devices: + if device.serial in by_serial: + raise FragmentAdapterConflict( + "GE Cloud discovery contains duplicate serial {}".format( + device.serial, + ) + ) + by_serial[device.serial] = device + if device.uuid is not None: + owner = uuid_owners.get(device.uuid) + if owner is not None and owner != device.serial: + raise FragmentAdapterConflict( + "GE Cloud UUID {} belongs to both {} and {}".format( + device.uuid, + owner, + device.serial, + ) + ) + uuid_owners[device.uuid] = device.serial + return tuple(sorted(devices, key=lambda item: (item.serial, item.kind))) + + +def _device_node(device, provider_id): + """Project one device without claiming read or control capabilities.""" + attributes = { + "serial": device.serial, + "geCloudKind": device.kind, + } + if device.online is not None: + attributes["online"] = device.online + if device.model is not None: + attributes["model"] = device.model + if device.site_id is not None: + attributes["siteId"] = device.site_id + if device.uuid is not None: + attributes["geCloudUuid"] = device.uuid + return { + "id": device.node_id, + "kind": _TOPOLOGY_KINDS[device.kind], + "attributes": attributes, + "accessPaths": [ + { + "id": "ge-cloud-api", + "provider": provider_id, + "preference": 0, + } + ], + "capabilities": [], + } + + +def _topology_document(provider_id, discovery_version, devices): + """Build one deterministic provider-owned v0.3 topology fragment.""" + return { + "topologyVersion": "0.3.0", + "scope": "fragment", + "docVersion": discovery_version, + "producer": { + "name": "GivEnergy Cloud", + "provider": provider_id, + "authority": 0, + }, + "nodes": [_device_node(device, provider_id) for device in devices], + } + + +def _reference_aliases(devices): + """Publish provider-local names that never grant materialization roles.""" + return tuple( + ProviderAlias( + name="serial:{}".format(device.serial), + node_id=device.node_id, + roles=frozenset((AliasRole.REFERENCE,)), + ) + for device in devices + ) + + +def _identity_aliases(devices): + """Publish strong serial and optional provider UUID identities. + + The deterministic node id remains provider-local metadata. It is not + asserted as a cross-provider ``lattice-node-id`` identity because another + integration may legitimately name the same serial differently. + """ + aliases = [] + for device in devices: + aliases.append( + ProviderIdentityAlias( + kind="serial", + value=device.serial, + node_id=device.node_id, + ) + ) + if device.uuid is not None: + aliases.append( + ProviderIdentityAlias( + kind="ge-cloud-uuid", + value=device.uuid, + node_id=device.node_id, + ) + ) + return tuple( + sorted( + aliases, + key=lambda item: (item.kind, item.value, item.node_id), + ) + ) + + +class GECloudFragmentPublisher: + """Adapt explicit cloud snapshots into one durable compiler fragment.""" + + def __init__(self, provider_id, state_store, enabled=False): + """Create an unwired publisher; disabled is the safe default.""" + if not isinstance(enabled, bool): + raise ValueError("enabled must be a boolean") + self._enabled = enabled + self._adapter = DurableFragmentAdapter(provider_id, state_store) + self.provider_id = self._adapter.provider_id + self._lock = threading.RLock() + try: + self._adapter.read_state() + except FragmentAdapterReadError as exc: + expected = "provider {} has no durable fragment".format(self.provider_id) + if str(exc) != expected: + raise + self._seeded = False + else: + self._seeded = True + + @property + def enabled(self): + """Return whether this explicitly constructed publisher accepts input.""" + return self._enabled + + @property + def generation(self): + """Fresh-read the current durable adapter generation.""" + return self.read_state().generation + + @property + def semantic_fingerprint(self): + """Fresh-read the generation-bound semantic fingerprint.""" + return self.read_state().semantic_fingerprint + + @property + def discovery_version(self): + """Fresh-read the current provider-owned discovery version.""" + state = self.read_state() + return _plain(state.snapshot.topology_fragment)["docVersion"] + + def lattice_fragment_adapter(self): + """Expose structural discovery only when enabled and durably seeded.""" + if not self._enabled or not self._seeded: + return None + self.read_state() + return self + + def read_state(self): + """Fresh-read the complete durable adapter state.""" + return self._adapter.read_state() + + def read_snapshot(self): + """Fresh-read the current immutable provider snapshot.""" + return self._adapter.read_snapshot() + + def subscribe_invalidation(self, listener): + """Subscribe to discovery, device, liveness, and removal changes.""" + return self._adapter.subscribe_invalidation(listener) + + def _current_state(self): + """Return the current state, treating an unseeded store as empty.""" + if not self._seeded: + return None + return self._adapter.read_state() + + def ingest_discovery( + self, + discovery_version, + devices, + health=_HEALTH_UNCHANGED, + ): + """Publish one accepted complete discovery/device snapshot.""" + if not self._enabled: + return False + discovery_version = _discovery_version(discovery_version) + devices = _normalize_devices(devices) + document = _topology_document( + self.provider_id, + discovery_version, + devices, + ) + + with self._lock: + current = self._current_state() + if current is not None and current.removed: + raise FragmentAdapterRemoved( + "provider {} was removed at generation {}; GE Cloud " + "discovery cannot re-enrol it".format( + self.provider_id, + current.generation, + ) + ) + if current is None: + next_health = ProviderHealth.DEGRADED if health is _HEALTH_UNCHANGED else _provider_health(health) + else: + previous = _plain(current.snapshot.topology_fragment) + previous_version = previous["docVersion"] + if discovery_version < previous_version: + return False + if discovery_version == previous_version and document != previous: + raise FragmentAdapterConflict( + "provider {} reused GE Cloud discovery version {} " + "for different content".format( + self.provider_id, + discovery_version, + ) + ) + next_health = current.snapshot.health if health is _HEALTH_UNCHANGED else _provider_health(health) + if discovery_version == previous_version and next_health is current.snapshot.health: + return False + + generation = 1 if current is None else current.generation + 1 + snapshot = ProviderSnapshot( + provider_id=self.provider_id, + generation=generation, + health=next_health, + topology_fragment=document, + aliases=_reference_aliases(devices), + identity_aliases=_identity_aliases(devices), + role_assignments=(), + config_projections=(), + ) + published = self._adapter.publish( + snapshot, + "GE Cloud discovery changed to version {}".format( + discovery_version, + ), + ) + if published: + self._seeded = True + return published + + def set_liveness(self, health): + """Publish provider liveness without changing the discovery document.""" + if not self._enabled: + return False + health = _provider_health(health) + with self._lock: + current = self._current_state() + if current is None: + raise FragmentAdapterReadError( + "GE Cloud discovery must be seeded before liveness", + ) + if current.removed: + raise FragmentAdapterRemoved( + "provider {} was removed at generation {}".format( + self.provider_id, + current.generation, + ) + ) + if current.snapshot.health is health: + return False + previous = current.snapshot + snapshot = ProviderSnapshot( + provider_id=self.provider_id, + generation=current.generation + 1, + health=health, + topology_fragment=_plain(previous.topology_fragment), + aliases=previous.aliases, + identity_aliases=previous.identity_aliases, + role_assignments=(), + config_projections=(), + ) + return self._adapter.publish( + snapshot, + "GE Cloud liveness changed to {}".format(health.value), + ) + + def remove(self): + """Publish an irreversible provider-removal tombstone.""" + if not self._enabled: + return False + with self._lock: + current = self._current_state() + if current is None: + raise FragmentAdapterReadError( + "GE Cloud discovery must be seeded before removal", + ) + if current.removed: + return False + return self._adapter.remove( + current.generation + 1, + "GE Cloud integration removed", + ) diff --git a/apps/predbat/tests/test_lattice_ge_cloud_fragment.py b/apps/predbat/tests/test_lattice_ge_cloud_fragment.py new file mode 100644 index 000000000..29170e1a6 --- /dev/null +++ b/apps/predbat/tests/test_lattice_ge_cloud_fragment.py @@ -0,0 +1,393 @@ +"""Tests for the pure GivEnergy Cloud Lattice fragment publisher.""" + +# cspell:ignore autoconfig + +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from lattice_autoconfig import ( # noqa: E402 + AliasRole, + CompileStatus, + ProviderHealth, + compile_auto_config, +) +from lattice_compiled_publication import ( # noqa: E402 + InMemoryCompiledLatticeStateStore, +) +from lattice_fragment_adapters import ( # noqa: E402 + FragmentAdapterConflict, + FragmentAdapterReadError, + FragmentAdapterRegistry, + FragmentAdapterRemoved, + InMemoryFragmentAdapterStateStore, +) +from lattice_gateway_fragment import ( # noqa: E402 + GatewayRetainedTopologyFragmentPublisher, +) +from lattice_ge_cloud_fragment import ( # noqa: E402 + GECloudDeviceSnapshot, + GECloudFragmentPublisher, +) + + +def device( + serial="SA2243G277", + kind="battery-inverter", + online=True, + model="All-In-One", + site_id=61435, + uuid=None, +): + """Build one explicit provider-local cloud device snapshot.""" + return GECloudDeviceSnapshot( + serial=serial, + kind=kind, + online=online, + model=model, + site_id=site_id, + uuid=uuid, + ) + + +def publisher(enabled=True, state_store=None, provider_id="ge-cloud"): + """Build one reference publisher and its in-memory durable store.""" + if state_store is None: + state_store = InMemoryFragmentAdapterStateStore() + return ( + GECloudFragmentPublisher( + provider_id, + state_store, + enabled=enabled, + ), + state_store, + ) + + +def gateway_topology(serial="SA2243G277", doc_version=1): + """Build a compact Gateway view of the same physical inverter.""" + return { + "topologyVersion": "0.3.0", + "scope": "fragment", + "docVersion": doc_version, + "producer": { + "name": "PredBat Gateway", + "provider": "predbat-gateway", + "authority": 10, + }, + "nodes": [ + { + "id": "gateway-inverter", + "kind": "inverter", + "attributes": {"serial": serial}, + "accessPaths": [ + { + "id": "gateway-mqtt", + "provider": "predbat-gateway", + "preference": 10, + } + ], + "capabilities": [], + } + ], + } + + +class TestGECloudFragmentPublisher(unittest.TestCase): + """Cloud discovery publishes immutable, REFERENCE-only snapshots.""" + + def test_default_off_has_no_discovery_or_durable_side_effect(self): + """Disabled construction never validates, publishes, or registers.""" + adapter, state_store = publisher(enabled=False) + + self.assertIsNone(adapter.lattice_fragment_adapter()) + self.assertFalse(adapter.ingest_discovery(-1, (object(),))) + self.assertFalse(adapter.set_liveness(True)) + self.assertFalse(adapter.remove()) + self.assertEqual(state_store.writes, 0) + with self.assertRaises(FragmentAdapterReadError): + adapter.read_state() + + def test_discovery_publishes_immutable_reference_only_snapshot(self): + """Explicit device state is detached and carries no write authority.""" + adapter, state_store = publisher() + devices = [ + device(serial="sa2243g277"), + device( + serial="evc-1", + kind="ev-charger", + online=None, + model="EVC", + uuid="evc-uuid-1", + ), + ] + + self.assertTrue( + adapter.ingest_discovery( + 7, + devices, + health=ProviderHealth.HEALTHY, + ) + ) + devices.append(device(serial="MUTATED")) + snapshot = adapter.read_snapshot() + nodes = snapshot.topology_fragment["nodes"] + + self.assertIs(adapter.lattice_fragment_adapter(), adapter) + self.assertEqual(state_store.writes, 1) + self.assertEqual(adapter.generation, 1) + self.assertEqual(adapter.discovery_version, 7) + self.assertEqual(len(adapter.semantic_fingerprint), 64) + self.assertEqual(snapshot.health, ProviderHealth.HEALTHY) + self.assertEqual(nodes[1]["attributes"]["siteId"], "61435") + self.assertEqual( + tuple(node["id"] for node in nodes), + ("ge-cloud:EVC-1", "ge-cloud:SA2243G277"), + ) + self.assertEqual(snapshot.role_assignments, ()) + self.assertEqual(snapshot.config_projections, ()) + self.assertTrue(all(alias.roles == frozenset((AliasRole.REFERENCE,)) for alias in snapshot.aliases)) + self.assertTrue(all(node["capabilities"] == () for node in nodes)) + self.assertEqual( + tuple((alias.kind, alias.value, alias.node_id) for alias in snapshot.identity_aliases), + ( + ("ge-cloud-uuid", "evc-uuid-1", "ge-cloud:EVC-1"), + ("serial", "EVC-1", "ge-cloud:EVC-1"), + ("serial", "SA2243G277", "ge-cloud:SA2243G277"), + ), + ) + + def test_device_order_and_serial_case_are_replay_stable(self): + """Equivalent provider snapshots do not invent new generations.""" + adapter, state_store = publisher() + first = ( + device(serial="inv-2", kind="pv-inverter"), + device(serial="inv-1"), + ) + replay = ( + device(serial="INV-1"), + device(serial="INV-2", kind="pv_inverter"), + ) + + self.assertTrue(adapter.ingest_discovery(3, first, health=True)) + self.assertFalse(adapter.ingest_discovery(3, replay, health=True)) + self.assertEqual(adapter.generation, 1) + self.assertEqual(state_store.writes, 1) + + def test_versions_are_monotonic_and_reuse_collision_fails_closed(self): + """Stale replay is ignored and same-version content reuse is rejected.""" + adapter, state_store = publisher() + adapter.ingest_discovery(5, (device(),), health=True) + before = adapter.read_state() + + self.assertFalse( + adapter.ingest_discovery( + 4, + (device(model="Newer-looking stale data"),), + health=False, + ) + ) + with self.assertRaisesRegex( + FragmentAdapterConflict, + "reused GE Cloud discovery version 5", + ): + adapter.ingest_discovery( + 5, + (device(model="Different"),), + ) + + self.assertEqual(adapter.read_state(), before) + self.assertEqual(state_store.writes, 1) + + def test_serial_and_uuid_collisions_fail_before_publication(self): + """Ambiguous strong identities cannot enter durable provider state.""" + adapter, state_store = publisher() + + with self.assertRaisesRegex(ValueError, "at least one"): + adapter.ingest_discovery(1, ()) + with self.assertRaisesRegex(ValueError, "online"): + device(online=1) + with self.assertRaisesRegex( + FragmentAdapterConflict, + "duplicate serial SA2243G277", + ): + adapter.ingest_discovery( + 1, + (device(), device(serial="sa2243g277")), + ) + with self.assertRaisesRegex( + FragmentAdapterConflict, + "belongs to both INV-1 and INV-2", + ): + adapter.ingest_discovery( + 1, + ( + device(serial="INV-1", uuid="shared-uuid"), + device(serial="INV-2", uuid="shared-uuid"), + ), + ) + + self.assertEqual(state_store.writes, 0) + with self.assertRaises(FragmentAdapterReadError): + adapter.read_state() + + def test_discovery_and_liveness_changes_trigger_fresh_read_composition(self): + """Every accepted integration change invalidates the common compiler.""" + adapter, _state_store = publisher() + adapter.ingest_discovery(1, (device(),), health=True) + reasons = [] + adapter.subscribe_invalidation( + lambda provider_id, generation, reason, feedback_token: reasons.append( + (provider_id, generation, reason, feedback_token), + ) + ) + registry = FragmentAdapterRegistry(enabled=True) + self.assertEqual(registry.discover((adapter,)), ("ge-cloud",)) + compiler = registry.create_compiler( + InMemoryCompiledLatticeStateStore(), + ) + + first = compiler.drain() + self.assertEqual(first.status, CompileStatus.FRESH) + self.assertEqual( + first.publication.provider_generations, + (("ge-cloud", 1),), + ) + + self.assertTrue( + adapter.ingest_discovery( + 2, + ( + device(), + device(serial="PV-1", kind="pv-inverter"), + ), + ) + ) + changed = compiler.drain() + self.assertEqual(changed.status, CompileStatus.FRESH) + self.assertEqual( + changed.publication.provider_generations, + (("ge-cloud", 2),), + ) + self.assertEqual( + tuple(node["id"] for node in changed.publication.plan.topology["nodes"]), + ("identity:serial:PV-1", "identity:serial:SA2243G277"), + ) + + self.assertTrue(adapter.set_liveness(False)) + offline = compiler.drain() + self.assertEqual(offline.status, CompileStatus.STALE) + self.assertTrue(offline.pending) + self.assertEqual(adapter.generation, 3) + self.assertEqual( + tuple(reasons), + ( + ( + "ge-cloud", + 2, + "GE Cloud discovery changed to version 2", + None, + ), + ( + "ge-cloud", + 3, + "GE Cloud liveness changed to offline", + None, + ), + ), + ) + + def test_liveness_is_independent_monotonic_and_preserves_topology(self): + """Provider health changes retain exact discovery contents.""" + adapter, state_store = publisher() + with self.assertRaisesRegex(FragmentAdapterReadError, "seeded"): + adapter.set_liveness(True) + adapter.ingest_discovery(1, (device(),)) + topology = adapter.read_snapshot().topology_fragment + + self.assertTrue(adapter.set_liveness(True)) + self.assertFalse(adapter.set_liveness(True)) + self.assertTrue(adapter.set_liveness(None)) + self.assertEqual(adapter.read_snapshot().health, ProviderHealth.DEGRADED) + self.assertEqual(adapter.read_snapshot().topology_fragment, topology) + self.assertEqual(adapter.generation, 3) + self.assertEqual(state_store.writes, 3) + + def test_serial_identity_composes_with_gateway_without_materialization(self): + """Cloud and Gateway references join without creating control roles.""" + cloud, _cloud_store = publisher() + cloud.ingest_discovery(1, (device(),), health=True) + gateway = GatewayRetainedTopologyFragmentPublisher( + "predbat-gateway", + InMemoryFragmentAdapterStateStore(), + enabled=True, + ) + gateway.ingest_retained_topology( + gateway_topology(), + online=True, + ) + + plan = compile_auto_config( + ( + gateway.read_snapshot(), + cloud.read_snapshot(), + ) + ) + + self.assertEqual(len(plan.topology["nodes"]), 1) + self.assertEqual( + plan.topology["nodes"][0]["id"], + "identity:serial:SA2243G277", + ) + self.assertEqual( + plan.provider_generations, + (("ge-cloud", 1), ("predbat-gateway", 1)), + ) + self.assertEqual(plan.role_assignments, ()) + self.assertEqual(plan.primary_targets, ()) + self.assertEqual(plan.control_targets, ()) + self.assertEqual(plan.config_arguments, ()) + self.assertEqual(dict(plan.projected_config), {}) + self.assertFalse(plan.materialization_readiness.ready) + + def test_removal_is_durable_invalidating_and_irreversible_by_replay(self): + """Ordinary discovery replay cannot re-enrol a removed integration.""" + adapter, state_store = publisher() + adapter.ingest_discovery(5, (device(),), health=True) + invalidations = [] + adapter.subscribe_invalidation(lambda provider_id, generation, reason, feedback_token: (invalidations.append((provider_id, generation, reason)))) + + self.assertTrue(adapter.remove()) + self.assertFalse(adapter.remove()) + before = adapter.read_state() + self.assertTrue(before.removed) + with self.assertRaises(FragmentAdapterRemoved): + adapter.read_snapshot() + with self.assertRaisesRegex( + FragmentAdapterRemoved, + "discovery cannot re-enrol", + ): + adapter.ingest_discovery(1, (device(),), health=True) + self.assertEqual(adapter.read_state(), before) + self.assertEqual(state_store.writes, 2) + self.assertEqual( + invalidations[-1], + ("ge-cloud", 2, "GE Cloud integration removed"), + ) + + restarted = GECloudFragmentPublisher( + "ge-cloud", + state_store, + enabled=True, + ) + self.assertTrue(restarted.read_state().removed) + with self.assertRaises(FragmentAdapterRemoved): + restarted.read_snapshot() + with self.assertRaises(FragmentAdapterRemoved): + restarted.ingest_discovery(99, (device(),)) + + +if __name__ == "__main__": + unittest.main()