Repository: Jc2k/aiohomekit

Issue:
Accessories that do not send normal advertisements after subscribing to notify need the ability to restore the GSN between restarts
```
Oct 30 21:16:54 homeassistant homeassistant[468]: Traceback (most recent call last):
Oct 30 21:16:54 homeassistant homeassistant[468]:   File "/usr/local/lib/python3.10/site-packages/aiohomekit/controller/ble/pairing.py", line 506, in _process_disconnected_events
Oct 30 21:16:54 homeassistant homeassistant[468]:     protocol_param = await self._process_disconnected_events_with_retry()
Oct 30 21:16:54 homeassistant homeassistant[468]:   File "/usr/local/lib/python3.10/site-packages/aiohomekit/controller/ble/pairing.py", line 154, in _async_operation_lock_wrap
Oct 30 21:16:54 homeassistant homeassistant[468]:     return await func(self, *args, **kwargs)
Oct 30 21:16:54 homeassistant homeassistant[468]:   File "/usr/local/lib/python3.10/site-packages/bleak_retry_connector/__init__.py", line 619, in _async_wrap_bluetooth_connection_error_retry
Oct 30 21:16:54 homeassistant homeassistant[468]:     return await func(*args, **kwargs)
Oct 30 21:16:54 homeassistant homeassistant[468]:   File "/usr/local/lib/python3.10/site-packages/aiohomekit/controller/ble/pairing.py", line 168, in _async_restore_and_resume
Oct 30 21:16:54 homeassistant homeassistant[468]:     await self._populate_accessories_and_characteristics()
Oct 30 21:16:54 homeassistant homeassistant[468]:   File "/usr/local/lib/python3.10/site-packages/aiohomekit/controller/ble/pairing.py", line 945, in _populate_accessories_and_characteristics
Oct 30 21:16:54 homeassistant homeassistant[468]:     made_connection = await self._ensure_connected(attempts)
Oct 30 21:16:54 homeassistant homeassistant[468]:   File "/usr/local/lib/python3.10/site-packages/aiohomekit/controller/ble/pairing.py", line 401, in _ensure_connected
Oct 30 21:16:54 homeassistant homeassistant[468]:     raise AccessoryNotFoundError(
Oct 30 21:16:54 homeassistant homeassistant[468]: aiohomekit.exceptions.AccessoryNotFoundError: [D0:72:0E:4B:7F:6C] (id=42:15:4C:F4:B9:DE): Could not find D0:72:0E:4B:7F:6C

```

Not sure sure how to handle this one as all the logic requires this.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/Jc2k/aiohomekit/pull/267?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
Base: **66.75**% // Head: **66.61**% // Decreases project coverage by **`-0.13%`** :warning:
> Coverage data is based on head [(`138fbe4`)](https://codecov.io/gh/Jc2k/aiohomekit/pull/267?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) compared to base [(`71a6136`)](https://codecov.io/gh/Jc2k/aiohomekit/commit/71a6136f0b4f8d67a4b8e3e72672937f66de930a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> Patch coverage: 34.48% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #267      +/-   ##
==========================================
- Coverage   66.75%   66.61%   -0.14%     
==========================================
  Files          71       71              
  Lines        6677     6701      +24     
==========================================
+ Hits         4457     4464       +7     
- Misses       2220     2237      +17     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `66.61% <34.48%> (-0.14%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/Jc2k/aiohomekit/pull/267?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [aiohomekit/controller/ble/pairing.py](https://codecov.io/gh/Jc2k/aiohomekit/pull/267/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YWlvaG9tZWtpdC9jb250cm9sbGVyL2JsZS9wYWlyaW5nLnB5) | `21.91% <11.11%> (-0.12%)` | :arrow_down: |
| [aiohomekit/controller/abstract.py](https://codecov.io/gh/Jc2k/aiohomekit/pull/267/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YWlvaG9tZWtpdC9jb250cm9sbGVyL2Fic3RyYWN0LnB5) | `81.08% <66.66%> (-0.41%)` | :arrow_down: |
| [aiohomekit/controller/ble/manufacturer\_data.py](https://codecov.io/gh/Jc2k/aiohomekit/pull/267/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YWlvaG9tZWtpdC9jb250cm9sbGVyL2JsZS9tYW51ZmFjdHVyZXJfZGF0YS5weQ==) | `60.65% <66.66%> (+0.31%)` | :arrow_up: |
| [aiohomekit/characteristic\_cache.py](https://codecov.io/gh/Jc2k/aiohomekit/pull/267/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YWlvaG9tZWtpdC9jaGFyYWN0ZXJpc3RpY19jYWNoZS5weQ==) | `69.64% <100.00%> (+0.55%)` | :arrow_up: |
| [aiohomekit/model/\_\_init\_\_.py](https://codecov.io/gh/Jc2k/aiohomekit/pull/267/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YWlvaG9tZWtpdC9tb2RlbC9fX2luaXRfXy5weQ==) | `95.19% <100.00%> (+0.02%)` | :arrow_up: |
| [aiohomekit/controller/ble/controller.py](https://codecov.io/gh/Jc2k/aiohomekit/pull/267/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YWlvaG9tZWtpdC9jb250cm9sbGVyL2JsZS9jb250cm9sbGVyLnB5) | `33.81% <0.00%> (-2.16%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/Jc2k/aiohomekit/pull/267?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).


Patch:
diff --git a/aiohomekit/characteristic_cache.py b/aiohomekit/characteristic_cache.py
index a6ecdb9..26417fc 100644
--- a/aiohomekit/characteristic_cache.py
+++ b/aiohomekit/characteristic_cache.py
@@ -40,6 +40,7 @@ class Pairing(TypedDict):
     config_num: int
     accessories: list[Any]
     broadcast_key: str | None
+    state_num: int | None
 
 
 class StorageLayout(TypedDict):
@@ -58,6 +59,7 @@ class CharacteristicCacheType(Protocol):
         config_num: int,
         accessories: list[Any],
         broadcast_key: str | None = None,
+        state_num: int | None = None,
     ) -> Pairing:
         pass
 
@@ -80,10 +82,14 @@ class CharacteristicCacheMemory:
         config_num: int,
         accessories: list[Any],
         broadcast_key: str | None = None,
+        state_num: int | None = None,
     ) -> Pairing:
         """Create a new pairing cache."""
         data = Pairing(
-            config_num=config_num, accessories=accessories, broadcast_key=broadcast_key
+            config_num=config_num,
+            accessories=accessories,
+            broadcast_key=broadcast_key,
+            state_num=state_num,
         )
         self.storage_data[homekit_id] = data
         return data
@@ -117,10 +123,11 @@ class CharacteristicCacheFile(CharacteristicCacheMemory):
         config_num: int,
         accessories: list[Any],
         broadcast_key: bytes | None = None,
+        state_num: int | None = None,
     ) -> Pairing:
         """Create a new pairing cache."""
         data = super().async_create_or_update_map(
-            homekit_id, config_num, accessories, broadcast_key
+            homekit_id, config_num, accessories, broadcast_key, state_num
         )
         self._do_save()
         return data
diff --git a/aiohomekit/controller/abstract.py b/aiohomekit/controller/abstract.py
index 97a8c79..647e132 100644
--- a/aiohomekit/controller/abstract.py
+++ b/aiohomekit/controller/abstract.py
@@ -137,6 +137,13 @@ class AbstractPairing(metaclass=ABCMeta):
             return None
         return self._accessories_state.broadcast_key
 
+    @property
+    def state_num(self) -> bytes | None:
+        """Returns gsn that is saved between restarts."""
+        if not self._accessories_state:
+            return None
+        return self._accessories_state.state_num
+
     @abstractmethod
     async def _process_disconnected_events(self):
         """Process any disconnected events that are available."""
@@ -206,15 +213,20 @@ class AbstractPairing(metaclass=ABCMeta):
             )
             return
         config_num = cache.get("config_num", 0)
+        state_num = cache.get("state_num")
         broadcast_key_hex = cache.get("broadcast_key")
         accessories = Accessories.from_list(cache["accessories"])
         self._accessories_state = AccessoriesState(
-            accessories, config_num, deserialize_broadcast_key(broadcast_key_hex)
+            accessories,
+            config_num,
+            deserialize_broadcast_key(broadcast_key_hex),
+            state_num,
         )
         logger.debug(
-            "%s: Accessories cache loaded (c#: %d) (has broadcast_key: %s)",
+            "%s: Accessories cache loaded (c#: %d) (gsn: %s) (has broadcast_key: %s)",
             self.name,
             config_num,
+            state_num,  # May be None
             bool(broadcast_key_hex),
         )
 
@@ -223,11 +235,12 @@ class AbstractPairing(metaclass=ABCMeta):
         accessories: list[dict[str, Any]],
         config_num: int,
         broadcast_key: bytes | None,
+        state_num: int | None = None,
     ) -> None:
         """Restore accessories from cache."""
         accessories = Accessories.from_list(accessories)
         self._accessories_state = AccessoriesState(
-            accessories, config_num, broadcast_key
+            accessories, config_num, broadcast_key, state_num
         )
         self._update_accessories_state_cache()
 
@@ -238,6 +251,7 @@ class AbstractPairing(metaclass=ABCMeta):
             self.config_num,
             self.accessories.serialize(),
             serialize_broadcast_key(self.broadcast_key),
+            self.state_num,
         )
 
     async def get_primary_name(self) -> str:
diff --git a/aiohomekit/controller/ble/controller.py b/aiohomekit/controller/ble/controller.py
index 56a96b3..3e9c8ca 100644
--- a/aiohomekit/controller/ble/controller.py
+++ b/aiohomekit/controller/ble/controller.py
@@ -74,9 +74,11 @@ class BleController(AbstractController):
             return
 
         if pairing := self.pairings.get(data.id):
-            if (old_description := pairing.description) and len(
-                old_description.name
-            ) > len(data.name):
+            if (
+                (old_description := pairing.description)
+                and old_description.name != old_description.address
+                and len(old_description.name) > len(data.name)
+            ):
                 #
                 # If we have a pairing and the name is longer than the one we
                 # just received, we assume the name is more accurate and
diff --git a/aiohomekit/controller/ble/manufacturer_data.py b/aiohomekit/controller/ble/manufacturer_data.py
index a2ba94b..8b5a64d 100644
--- a/aiohomekit/controller/ble/manufacturer_data.py
+++ b/aiohomekit/controller/ble/manufacturer_data.py
@@ -56,6 +56,22 @@ class HomeKitAdvertisement(AbstractDescription):
             address=address,
         )
 
+    @classmethod
+    def from_cache(
+        cls, address: str, id: str, config_num: int, state_num: int
+    ) -> HomeKitAdvertisement:
+        """Create a HomeKitAdvertisement from a cache entry."""
+        return cls(
+            name=address,
+            id=id,
+            category=Categories(0),
+            status_flags=StatusFlags(0),
+            config_num=config_num,
+            state_num=state_num,
+            setup_hash=b"",
+            address=address,
+        )
+
     @classmethod
     def from_advertisement(
         cls, device: BLEDevice, advertisement_data: AdvertisementData
diff --git a/aiohomekit/controller/ble/pairing.py b/aiohomekit/controller/ble/pairing.py
index 396c727..fe737e3 100644
--- a/aiohomekit/controller/ble/pairing.py
+++ b/aiohomekit/controller/ble/pairing.py
@@ -268,6 +268,14 @@ class BlePairing(AbstractPairing):
 
         self._last_seen = time.monotonic() if description else NEVER_TIME
 
+        if not description and self.state_num:
+            self.description = HomeKitAdvertisement.from_cache(
+                address=self.address,
+                id=self.id,
+                config_num=self.config_num,
+                state_num=self.state_num,
+            )
+
         # Encryption
         self._derive = None
         self._session_id = None
@@ -355,6 +363,18 @@ class BlePairing(AbstractPairing):
         """The transport used for the connection."""
         return Transport.BLE
 
+    def _update_state_num(self, state_num: int) -> None:
+        """Update the state number."""
+        self.description.state_num = state_num
+        self._update_cached_state_num(state_num)
+
+    def _update_cached_state_num(self, state_num: int) -> None:
+        """Update the cached state number which is restored between restarts."""
+        old_state_num = self._accessories_state.state_num
+        self._accessories_state.state_num = state_num
+        if old_state_num != state_num:
+            self._update_accessories_state_cache()
+
     def _async_ble_update(
         self, device: BLEDevice, ble_advertisement: AdvertisementData
     ) -> None:
@@ -380,6 +400,7 @@ class BlePairing(AbstractPairing):
             )
 
         super()._async_description_update(description)
+        self._update_cached_state_num(description.state_num)
 
     async def _async_request(
         self,
@@ -510,7 +531,7 @@ class BlePairing(AbstractPairing):
                     if not self._fetched_gsn_this_session and (
                         protocol_param := await self._get_all_protocol_params()
                     ):
-                        self.description.state_num = protocol_param.state_number
+                        self._update_state_num(protocol_param.state_number)
 
                     if not self._had_notify_this_session:
                         self._had_notify_this_session = True
@@ -523,12 +544,13 @@ class BlePairing(AbstractPairing):
                         # is NOT increased when we get a notify so we need
                         # to do it here.
                         #
-                        self.description.state_num += 1
-                        if self.description.state_num >= MAX_GSN:
-                            self.description.state_num = 1
+                        new_state_num = self.description.state_num + 1
+                        if new_state_num >= MAX_GSN:
+                            new_state_num = 1
                             # GSN rolled over which invalidates the broadcast
                             # encryption key. We need to re-fetch it.
                             await self._async_set_broadcast_encryption_key()
+                        self._update_state_num(new_state_num)
 
         def _callback(id: int, data: bytes) -> None:
             logger.debug("%s: Received event for iid=%s: %s", self.name, iid, data)
@@ -618,7 +640,7 @@ class BlePairing(AbstractPairing):
                 return
 
             if protocol_param:
-                self.description.state_num = protocol_param.state_number
+                self._update_state_num(protocol_param.state_number)
 
     @operation_lock
     @retry_bluetooth_connection_error()
@@ -947,7 +969,7 @@ class BlePairing(AbstractPairing):
         """
         if not self.accessories and self.description:
             self._accessories_state = AccessoriesState(
-                Accessories(), -1, self.broadcast_key
+                Accessories(), -1, self.broadcast_key, self.state_num
             )
             return self.description.name
         return await super().get_primary_name()
@@ -1200,7 +1222,7 @@ class BlePairing(AbstractPairing):
         # the state number again to make sure we are in sync
         protocol_param = await self._get_all_protocol_params()
         if protocol_param:
-            self.description.state_num = protocol_param.state_number
+            self._update_state_num(protocol_param.state_number)
         self._async_schedule_start_notify_subscriptions()
 
     @operation_lock
diff --git a/aiohomekit/controller/coap/pairing.py b/aiohomekit/controller/coap/pairing.py
index b17242f..017587b 100644
--- a/aiohomekit/controller/coap/pairing.py
+++ b/aiohomekit/controller/coap/pairing.py
@@ -50,8 +50,9 @@ class CoAPPairing(ZeroconfPairing):
 
     def _async_endpoint_changed(self) -> None:
         """The IP/Port has changed, so close connection if active then reconnect."""
-        description = self.owner.description
-        self.connection.address = f"[{description.address}]:{description.port}"
+        self.connection.address = (
+            f"[{self.description.address}]:{self.description.port}"
+        )
         async_create_task(self.connection.reconnect_soon())
 
     @property
diff --git a/aiohomekit/model/__init__.py b/aiohomekit/model/__init__.py
index 988866b..39e58a8 100644
--- a/aiohomekit/model/__init__.py
+++ b/aiohomekit/model/__init__.py
@@ -351,6 +351,9 @@ class Accessories:
     def aid(self, aid: int) -> Accessory:
         return self._aid_to_accessory[aid]
 
+    def has_aid(self, aid: int) -> bool:
+        return aid in self._aid_to_accessory
+
     def process_changes(self, changes: dict[tuple[int, int], Any]) -> None:
         for ((aid, iid), value) in changes.items():
             accessory = self.aid(aid)
@@ -373,3 +376,4 @@ class AccessoriesState:
     accessories: Accessories
     config_num: int
     broadcast_key: bytes | None = None
+    state_num: int | None = None


Test patch:
diff --git a/tests/test_model.py b/tests/test_model.py
index fdfc74f..21ecda3 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -125,6 +125,9 @@ def test_get_by_iid():
     name = "Hue dimmer switch button 3"
 
     bridge = Accessories.from_file("tests/fixtures/hue_bridge.json")
+    assert bridge.has_aid(6623462389072572) is True
+    assert bridge.has_aid(9999) is False
+
     accessory = bridge.aid(6623462389072572)
 
     char = accessory.characteristics.iid(588410716196)