Skip to content

Commit 6fccd07

Browse files
authored
Merge pull request #139 from jleinenbach/1.7.0-3
1.7.0-3 Bugfixes
2 parents 827343f + 65c311e commit 6fccd07

121 files changed

Lines changed: 11958 additions & 2192 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,36 @@ Prefer the executable name when it is available; fall back to the module form wh
365365
* **Synchronization points:** Keep `custom_components/googlefindmy/manifest.json`, `custom_components/googlefindmy/requirements.txt`, `pyproject.toml`, and `custom_components/googlefindmy/requirements-dev.txt` aligned. When bumping versions, check whether other files (for example, `hacs.json` or helpers under `script/`) must change as well.
366366
* **Upgrade workflow:** With internet access, perform dependency maintenance via `pip install`, `pip-compile`, `pip-audit`, `poetry update` (if relevant), and `python -m pip list --outdated`. Afterwards rerun tests/linters and document the outcomes.
367367
* **Change notes:** Record adjusted minimum versions or dropped legacy releases in the PR description and, when needed, in `CHANGELOG.md` or `README.md`.
368+
369+
### Poetry lock file management
370+
371+
**Critical:** After ANY change to `pyproject.toml`, regenerate `poetry.lock` with `poetry lock` before committing. CI will fail with "pyproject.toml changed significantly since poetry.lock was last generated" if the content-hash doesn't match.
372+
373+
**Correct workflow:**
374+
```bash
375+
# 1. Edit pyproject.toml (e.g., change dependency version)
376+
# 2. Regenerate lock file
377+
poetry lock
378+
379+
# 3. Verify lock is in sync
380+
poetry check
381+
382+
# 4. Commit BOTH files together
383+
git add pyproject.toml poetry.lock
384+
git commit -m "chore: update dependency X to version Y"
385+
```
386+
387+
**Common mistakes to avoid:**
388+
- Committing `pyproject.toml` without regenerating `poetry.lock`
389+
- Running `poetry install` without first running `poetry lock` after `pyproject.toml` changes
390+
- Using `--no-update` flag when dependencies need updating
391+
392+
**CI failure pattern:**
393+
```
394+
pyproject.toml changed significantly since poetry.lock was last generated.
395+
Run `poetry lock` to fix the lock file.
396+
```
397+
368398
* **Manifest compatibility (Jan 2025):** The shared CI still ships a `script.hassfest` build that rejects the `homeassistant` manifest key. Until upstream relaxes the schema for custom integrations, do **not** add `"homeassistant": "<version>"` to `custom_components/googlefindmy/manifest.json` or `hacs.json`. Track the minimum supported Home Assistant core release in documentation/tests instead.
369399

370400
## Maintenance mode
@@ -728,6 +758,16 @@ artifacts remain exempt when explicitly flagged by repo configuration).
728758
* Repairs/Diagnostics: provide both; redact aggressively.
729759
* Storage: use `helpers.storage.Store` for tokens/state; throttle writes (batch/merge).
730760
* System health: prefer the `SystemHealthRegistration` helper (`homeassistant.components.system_health.SystemHealthRegistration`) when available and keep the legacy component import only as a guarded fallback.
761+
* **Entity naming** (HA Best Practice, ref: [Adopting a new way to name entities](https://developers.home-assistant.io/blog/2022/07/10/entity_naming/)):
762+
- Always set `_attr_has_entity_name = True` on entity classes.
763+
- **Primary entity** (represents the device itself): set `_attr_name = None` so it inherits only the device name (e.g., "Galaxy S25 Ultra").
764+
- **Secondary entities** (additional features): use `translation_key` with a `name` in translations; HA auto-composes the friendly name as "Device Name + Translation" (e.g., "Galaxy S25 Ultra Last location").
765+
- **Translation files**: for the primary entity's `translation_key`, **omit** the `"name"` key entirely (presence of `"name"` would append a suffix); for secondary entities, **include** the `"name"` key with the suffix text.
766+
- Never set `_attr_name` dynamically at runtime (e.g., in coordinator update callbacks) when using `has_entity_name=True`—the device registry is the single source of truth for the device name.
767+
- **CRITICAL: `_attr_name = None` vs. attribute not set** — These behave differently with `has_entity_name=True`:
768+
- `_attr_name = None` (explicitly set) → entity inherits **only** the device name, no suffix
769+
- `_attr_name` **not set** (attribute doesn't exist) → name comes from `translation_key`
770+
- If a parent class sets `_attr_name = None` in `__init__()` and a child class needs the translation-based name, the child must **delete** the attribute after `super().__init__()`: `del self._attr_name`
731771

732772
### 11.8 Release & operations
733773

README.md

Lines changed: 55 additions & 39 deletions
Large diffs are not rendered by default.

custom_components/googlefindmy/Auth/aas_token_retrieval.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
from .gpsoauth_loader import (
5454
gpsoauth as _gpsoauth_proxy,
5555
)
56-
from .token_cache import TokenCache, async_get_all_cached_values
56+
from .token_cache import TokenCache
5757
from .username_provider import username_string
5858

5959
_LOGGER = logging.getLogger(__name__)
@@ -366,29 +366,6 @@ async def _generate_aas_token(*, cache: TokenCache) -> str: # noqa: PLR0912, PL
366366
)
367367
break
368368

369-
# Fallback 3: Try global cache for ADM tokens if entry cache had none (validation scenario)
370-
if not oauth_token and cache:
371-
try:
372-
all_cached_global = await async_get_all_cached_values()
373-
for key, value in all_cached_global.items():
374-
if (
375-
isinstance(key, str)
376-
and key.startswith("adm_token_")
377-
and isinstance(value, str)
378-
and value
379-
):
380-
oauth_token = value
381-
extracted_username = key.replace("adm_token_", "", 1)
382-
if extracted_username and "@" in extracted_username:
383-
username = extracted_username
384-
_LOGGER.info(
385-
"Using existing ADM token from global cache for OAuth exchange.",
386-
extra={"user": _mask_email_for_logs(username)},
387-
)
388-
break
389-
except Exception: # noqa: BLE001
390-
pass
391-
392369
if not oauth_token:
393370
raise ValueError(
394371
"No OAuth token available; please configure the integration with a valid token."

custom_components/googlefindmy/Auth/fcm_receiver.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,9 @@ def _on_credentials_updated(self, creds: Any) -> None:
155155
try:
156156
if self._cache is not None:
157157
if creds is None:
158-
self._cache._data.pop("fcm_credentials", None)
158+
self._cache.sync_pop("fcm_credentials")
159159
else:
160-
self._cache._data["fcm_credentials"] = creds
160+
self._cache.sync_set("fcm_credentials", creds)
161161
else:
162162
set_cached_value("fcm_credentials", creds)
163163
self._creds = creds
@@ -203,5 +203,5 @@ def _read_cached_credentials(self) -> Any:
203203
"""Return credentials from the selected cache without raising."""
204204

205205
if self._cache is not None:
206-
return self._cache._data.get("fcm_credentials")
206+
return self._cache.sync_get("fcm_credentials")
207207
return get_cached_value("fcm_credentials")

custom_components/googlefindmy/Auth/fcm_receiver_ha.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -985,7 +985,10 @@ def register_coordinator(self, coordinator: Any) -> None:
985985

986986
pending_creds = self._pending_creds.pop(entry.entry_id, None)
987987
if pending_creds is not None:
988-
asyncio.create_task(cache.set("fcm_credentials", pending_creds))
988+
self._dispatch_to_hass_loop(
989+
cache.set("fcm_credentials", pending_creds),
990+
label=f"set_pending_creds_{entry.entry_id}",
991+
)
989992

990993
pending_tokens = self._pending_routing_tokens.pop(entry.entry_id, set())
991994

@@ -1007,21 +1010,30 @@ async def _flush_tokens() -> None:
10071010
err,
10081011
)
10091012

1010-
asyncio.create_task(_flush_tokens())
1013+
self._dispatch_to_hass_loop(
1014+
_flush_tokens(),
1015+
label=f"flush_pending_tokens_{entry.entry_id}",
1016+
)
10111017

10121018
# Mirror any known credentials to this entry cache
10131019
try:
10141020
creds = self.creds.get(entry.entry_id)
10151021
if creds and cache is not None:
1016-
asyncio.create_task(cache.set("fcm_credentials", creds))
1022+
self._dispatch_to_hass_loop(
1023+
cache.set("fcm_credentials", creds),
1024+
label=f"mirror_creds_{entry.entry_id}",
1025+
)
10171026
except Exception as err:
10181027
_LOGGER.debug("Entry-scoped credentials persistence skipped: %s", err)
10191028

10201029
# Update routing with any token we already have
10211030
token = self.get_fcm_token(entry.entry_id)
10221031
if token:
10231032
self._update_token_routing(token, {entry.entry_id})
1024-
asyncio.create_task(self._persist_routing_token(entry.entry_id, token))
1033+
self._dispatch_to_hass_loop(
1034+
self._persist_routing_token(entry.entry_id, token),
1035+
label=f"persist_routing_token_{entry.entry_id}",
1036+
)
10251037

10261038
# Load persisted routing tokens for this entry and map them as well
10271039
if cache is not None:
@@ -1040,10 +1052,16 @@ async def _load_tokens() -> None:
10401052
err,
10411053
)
10421054

1043-
asyncio.create_task(_load_tokens())
1055+
self._dispatch_to_hass_loop(
1056+
_load_tokens(),
1057+
label=f"load_persisted_tokens_{entry.entry_id}",
1058+
)
10441059

10451060
# Start supervisor for this entry
1046-
asyncio.create_task(self._start_supervisor_for_entry(entry.entry_id, cache))
1061+
self._dispatch_to_hass_loop(
1062+
self._start_supervisor_for_entry(entry.entry_id, cache),
1063+
label=f"start_supervisor_{entry.entry_id}",
1064+
)
10471065

10481066
def unregister_coordinator(self, coordinator: Any) -> None:
10491067
"""Unregister a coordinator (sync; safe for async_on_unload)."""
@@ -1129,6 +1147,18 @@ async def _handle_notification_async(
11291147
await self._run_callback_async(cb, canonic_id, hex_string)
11301148
return
11311149

1150+
# Log FCM pushes that have no registered callback (e.g. sound
1151+
# confirmations, device status updates). This fires only in
1152+
# response to a user-initiated action (Play Sound button etc.)
1153+
# so it does not create log spam during normal operation.
1154+
_LOGGER.debug(
1155+
"FCM push for %s has no registered callback "
1156+
"(may be action confirmation): payload_len=%d, hex_prefix=%s",
1157+
canonic_id[:8],
1158+
len(hex_string),
1159+
hex_string[:120] if hex_string else "(empty)",
1160+
)
1161+
11321162
tracked = [
11331163
c for c in target_coordinators if self._is_tracked(c, canonic_id)
11341164
]
@@ -1668,12 +1698,18 @@ def _on_credentials_updated_for_entry(self, entry_id: str, creds: Any) -> None:
16681698
token = self.get_fcm_token(entry_id)
16691699
if token:
16701700
self._update_token_routing(token, {entry_id})
1671-
asyncio.create_task(self._persist_routing_token(entry_id, token))
1701+
self._dispatch_to_hass_loop(
1702+
self._persist_routing_token(entry_id, token),
1703+
label=f"persist_routing_token_{entry_id}",
1704+
)
16721705
self._clear_fatal_error_for_entry(
16731706
entry_id, reason="Credentials updated for entry"
16741707
)
16751708

1676-
asyncio.create_task(self._async_save_credentials_for_entry(entry_id))
1709+
self._dispatch_to_hass_loop(
1710+
self._async_save_credentials_for_entry(entry_id),
1711+
label=f"save_credentials_{entry_id}",
1712+
)
16771713
_LOGGER.info("[entry=%s] FCM credentials updated", entry_id)
16781714

16791715
async def _async_save_credentials_for_entry(self, entry_id: str) -> None:
@@ -1755,7 +1791,7 @@ async def async_stop(self, timeout: float = 5.0) -> None:
17551791
eid,
17561792
timeout,
17571793
)
1758-
except (ConnectionError, TimeoutError) as err:
1794+
except ConnectionError as err:
17591795
_LOGGER.debug("[entry=%s] FCM client stop network error: %s", eid, err)
17601796
except Exception as err: # noqa: BLE001
17611797
_LOGGER.debug(

custom_components/googlefindmy/Auth/firebase_messaging/fcmpushclient.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
from typing import TYPE_CHECKING, Any, cast
4444

4545
from aiohttp import ClientSession
46-
from cryptography.hazmat.backends import default_backend
4746
from cryptography.hazmat.primitives.serialization import load_der_private_key
4847

4948
import http_ece
@@ -435,8 +434,8 @@ def _decrypt_raw_data(
435434
salt_str: str,
436435
raw_data: bytes,
437436
) -> bytes:
438-
crypto_key = urlsafe_b64decode(crypto_key_str.encode("ascii"))
439-
salt = urlsafe_b64decode(salt_str.encode("ascii"))
437+
crypto_key = urlsafe_b64decode(crypto_key_str.encode("ascii") + b"=" * (-len(crypto_key_str) % 4))
438+
salt = urlsafe_b64decode(salt_str.encode("ascii") + b"=" * (-len(salt_str) % 4))
440439

441440
keys_section = credentials.get("keys")
442441
if not isinstance(keys_section, Mapping):
@@ -447,11 +446,9 @@ def _decrypt_raw_data(
447446
if not (isinstance(private_value, str) and isinstance(secret_value, str)):
448447
raise ValueError("Invalid key values in credential payload")
449448

450-
der_data = urlsafe_b64decode(private_value.encode("ascii") + b"========")
451-
secret = urlsafe_b64decode(secret_value.encode("ascii") + b"========")
452-
privkey = load_der_private_key(
453-
der_data, password=None, backend=default_backend()
454-
)
449+
der_data = urlsafe_b64decode(private_value.encode("ascii") + b"=" * (-len(private_value) % 4))
450+
secret = urlsafe_b64decode(secret_value.encode("ascii") + b"=" * (-len(secret_value) % 4))
451+
privkey = load_der_private_key(der_data, password=None)
455452
decrypted = http_decrypt(
456453
raw_data,
457454
salt=salt,
@@ -473,6 +470,20 @@ def _app_data_by_key(
473470
return ""
474471
raise RuntimeError(f"couldn't find in app_data {key}")
475472

473+
@staticmethod
474+
def _extract_header_param(header: str, param: str) -> str:
475+
"""Extract a named parameter from a semicolon-separated header value.
476+
477+
FCM headers like crypto-key and encryption use the format
478+
``key=value;key2=value2``. Blindly slicing off a fixed prefix
479+
breaks when extra parameters (e.g. ``p256ecdsa=...``) are present.
480+
"""
481+
for part in header.split(";"):
482+
key, _, value = part.strip().partition("=")
483+
if key == param:
484+
return value
485+
raise ValueError(f"Parameter '{param}' not found in header: {header}")
486+
476487
def _handle_data_message(
477488
self,
478489
msg: DataMessageStanza,
@@ -490,8 +501,12 @@ def _handle_data_message(
490501
):
491502
# The deleted_messages message does not contain data.
492503
return
493-
crypto_key = self._app_data_by_key(msg, "crypto-key")[3:] # strip dh=
494-
salt = self._app_data_by_key(msg, "encryption")[5:] # strip salt=
504+
crypto_key = self._extract_header_param(
505+
self._app_data_by_key(msg, "crypto-key"), "dh"
506+
)
507+
salt = self._extract_header_param(
508+
self._app_data_by_key(msg, "encryption"), "salt"
509+
)
495510
subtype = self._app_data_by_key(msg, "subtype")
496511
if TYPE_CHECKING:
497512
assert self.credentials

custom_components/googlefindmy/Auth/firebase_messaging/proto/android_checkin_pb2.py

Lines changed: 15 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)