├── icon.png ├── custom_components └── googlefindmy │ ├── icon.png │ ├── Auth │ ├── __init__.py │ ├── firebase_messaging │ │ ├── proto │ │ │ ├── __init__.py │ │ │ ├── android_checkin_pb2.py │ │ │ ├── checkin_pb2.py │ │ │ ├── android_checkin.proto │ │ │ ├── checkin.proto │ │ │ ├── mcs_pb2.py │ │ │ ├── mcs.proto │ │ │ └── android_checkin_pb2.pyi │ │ ├── __init__.py │ │ └── const.py │ ├── auth_flow.py │ ├── spot_token_retrieval.py │ ├── username_provider.py │ ├── fcm_receiver.py │ └── token_retrieval.py │ ├── NovaApi │ ├── __init__.py │ ├── ExecuteAction │ │ ├── __init__.py │ │ ├── PlaySound │ │ │ ├── __init__.py │ │ │ ├── sound_request.py │ │ │ ├── start_sound_request.py │ │ │ └── stop_sound_request.py │ │ ├── LocateTracker │ │ │ ├── __init__.py │ │ │ └── decrypted_location.py │ │ └── nbe_execute_action.py │ ├── ListDevices │ │ ├── __init__.py │ │ └── nbe_list_devices.py │ ├── util.py │ └── scopes.py │ ├── FMDNCrypto │ ├── __init__.py │ ├── sha.py │ ├── key_derivation.py │ └── eid_generator.py │ ├── KeyBackup │ ├── __init__.py │ ├── shared_key_request.py │ ├── response_parser.py │ ├── lskf_hasher.py │ └── shared_key_flow.py │ ├── SpotApi │ ├── __init__.py │ ├── CreateBleDevice │ │ ├── __init__.py │ │ ├── config.py │ │ ├── util.py │ │ └── create_ble_device.py │ ├── GetEidInfoForE2eeDevices │ │ ├── __init__.py │ │ └── get_eid_info_request.py │ ├── UploadPrecomputedPublicKeyIds │ │ ├── __init__.py │ │ └── upload_precomputed_public_key_ids.py │ └── grpc_parser.py │ ├── ProtoDecoders │ ├── __init__.py │ ├── LocationReportsUpload.proto │ ├── Common.proto │ ├── Common_pb2.py │ ├── LocationReportsUpload_pb2.py │ ├── LocationReportsUpload_pb2.pyi │ ├── Common_pb2.pyi │ └── DeviceUpdate.proto │ ├── requirements.txt │ ├── example_data_provider.py │ ├── manifest.json │ ├── services.yaml │ ├── chrome_driver.py │ ├── strings.json │ ├── tests │ └── test_diagnostics.py │ ├── binary_sensor.py │ ├── location_recorder.py │ ├── get_oauth_token.py │ └── translations │ ├── en.json │ └── pl.json ├── hacs.json └── README.md /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BSkando/GoogleFindMy-HA/HEAD/icon.png -------------------------------------------------------------------------------- /custom_components/googlefindmy/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BSkando/GoogleFindMy-HA/HEAD/custom_components/googlefindmy/icon.png -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/FMDNCrypto/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/KeyBackup/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/ProtoDecoders/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ExecuteAction/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ListDevices/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/CreateBleDevice/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ExecuteAction/PlaySound/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ExecuteAction/LocateTracker/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/GetEidInfoForE2eeDevices/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/UploadPrecomputedPublicKeyIds/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Google Find My Device", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "iot_class": "cloud_polling", 6 | "domains": ["device_tracker"], 7 | "homeassistant": "2024.1.0" 8 | } 9 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/util.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | import uuid 7 | 8 | def generate_random_uuid(): 9 | return str(uuid.uuid4()) -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/scopes.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | # Scope for executing actions 7 | NOVA_ACTION_API_SCOPE = "nbe_execute_action" 8 | 9 | # Scope for listing devices 10 | NOVA_LIST_DEVICES_API_SCOPE = "nbe_list_devices" 11 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/CreateBleDevice/config.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | from custom_components.googlefindmy.SpotApi.CreateBleDevice.util import hours_to_seconds 7 | 8 | mcu_fast_pair_model_id = "003200" 9 | max_truncated_eid_seconds_server = hours_to_seconds(4*24) -------------------------------------------------------------------------------- /custom_components/googlefindmy/requirements.txt: -------------------------------------------------------------------------------- 1 | undetected-chromedriver>=3.5.5 2 | selenium>=4.27.1 3 | gpsoauth>=1.1.1 4 | requests>=2.32.3 5 | beautifulsoup4>=4.12.3 6 | pyscrypt>=1.6.2 7 | cryptography>=43.0.3 8 | pycryptodomex>=3.21.0 9 | ecdsa>=0.19.0 10 | pytz>=2024.2 11 | protobuf>=5.28.3 12 | frida>=16.5.6 13 | httpx>=0.28.0 14 | h2>=4.1.0 15 | setuptools>=75.6.0 16 | aiohttp>=3.11.8 17 | http_ece>=1.1.0 -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/CreateBleDevice/util.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | def flip_bits(data: bytes, enabled: bool) -> bytes: 7 | """Flips all bits in each byte of the given byte sequence.""" 8 | if enabled: 9 | return bytes(b ^ 0xFF for b in data) 10 | 11 | return data 12 | 13 | 14 | def hours_to_seconds(hours): 15 | return hours * 3600 -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ExecuteAction/LocateTracker/decrypted_location.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | class WrappedLocation: 7 | def __init__(self, decrypted_location, time, accuracy, status, is_own_report, name): 8 | self.time = time 9 | self.status = status 10 | self.decrypted_location = decrypted_location 11 | self.is_own_report = is_own_report 12 | self.accuracy = accuracy 13 | self.name = name -------------------------------------------------------------------------------- /custom_components/googlefindmy/example_data_provider.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example data provider for Google Find My Device integration. 3 | This module provides example/placeholder data for testing and development. 4 | """ 5 | 6 | def get_example_data(key): 7 | """Return example data for the given key.""" 8 | examples = { 9 | "sample_identity_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 10 | "sample_owner_key": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", 11 | "sample_device_id": "example_device_12345", 12 | "sample_encrypted_data": "example_encrypted_payload", 13 | } 14 | 15 | return examples.get(key, "") -------------------------------------------------------------------------------- /custom_components/googlefindmy/FMDNCrypto/sha.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | import hashlib 7 | import hmac 8 | 9 | def calculate_truncated_sha256(identity_key: bytes, operation: int) -> bytes: 10 | identity_key_bytes = identity_key 11 | data = identity_key_bytes + bytes([operation]) 12 | 13 | sha256_hash = hashlib.sha256(data).digest() 14 | truncated_hash = sha256_hash[:8] 15 | 16 | return truncated_hash 17 | 18 | 19 | def calculate_hmac_sha256(key, message): 20 | hmac_obj = hmac.new(key, message, hashlib.sha256) 21 | return hmac_obj.hexdigest() -------------------------------------------------------------------------------- /custom_components/googlefindmy/FMDNCrypto/key_derivation.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | from custom_components.googlefindmy.FMDNCrypto.sha import calculate_truncated_sha256 7 | 8 | class FMDNOwnerOperations: 9 | 10 | def __init__(self): 11 | self.recovery_key = None 12 | self.ringing_key = None 13 | self.tracking_key = None 14 | 15 | def generate_keys(self, identity_key: bytes): 16 | 17 | try: 18 | self.recovery_key = calculate_truncated_sha256(identity_key, 0x01) 19 | self.ringing_key = calculate_truncated_sha256(identity_key, 0x02) 20 | self.tracking_key = calculate_truncated_sha256(identity_key, 0x03) 21 | 22 | except Exception as e: 23 | print(str(e)) -------------------------------------------------------------------------------- /custom_components/googlefindmy/ProtoDecoders/LocationReportsUpload.proto: -------------------------------------------------------------------------------- 1 | // 2 | // GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | // Copyright © 2024 Leon Böttger. All rights reserved. 4 | // 5 | 6 | syntax = "proto3"; 7 | import "ProtoDecoders/Common.proto"; 8 | 9 | message LocationReportsUpload { 10 | repeated Report reports = 1; 11 | ClientMetadata clientMetadata = 2; 12 | uint64 random1 = 3; 13 | uint64 random2 = 4; 14 | } 15 | 16 | message Report { 17 | Advertisement advertisement = 1; 18 | Time time = 4; 19 | LocationReport location = 6; 20 | } 21 | 22 | message Advertisement { 23 | Identifier identifier = 5; 24 | uint32 unwantedTrackingModeEnabled = 6; 25 | } 26 | 27 | message Identifier { 28 | bytes truncatedEid = 6; 29 | bytes canonicDeviceId = 7; 30 | } 31 | 32 | message ClientMetadata { 33 | ClientVersionInformation version = 2; 34 | } 35 | 36 | message ClientVersionInformation { 37 | string playServicesVersion = 1; 38 | } -------------------------------------------------------------------------------- /custom_components/googlefindmy/ProtoDecoders/Common.proto: -------------------------------------------------------------------------------- 1 | // 2 | // GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | // Copyright © 2024 Leon Böttger. All rights reserved. 4 | // 5 | 6 | syntax = "proto3"; 7 | 8 | message Time { 9 | uint32 seconds = 1; 10 | uint32 nanos = 2; 11 | } 12 | 13 | message LocationReport { 14 | SemanticLocation semanticLocation = 5; 15 | GeoLocation geoLocation = 10; 16 | Status status = 11; 17 | } 18 | 19 | message SemanticLocation { 20 | string locationName = 1; 21 | } 22 | 23 | enum Status { 24 | SEMANTIC = 0; 25 | LAST_KNOWN = 1; 26 | CROWDSOURCED = 2; 27 | AGGREGATED = 3; 28 | } 29 | 30 | message GeoLocation { 31 | EncryptedReport encryptedReport = 1; 32 | uint32 deviceTimeOffset = 2; 33 | float accuracy = 3; 34 | } 35 | 36 | message EncryptedReport { 37 | bytes publicKeyRandom = 1; 38 | bytes encryptedLocation = 2; 39 | bool isOwnReport = 3; 40 | } 41 | 42 | message GetEidInfoForE2eeDevicesRequest { 43 | int32 ownerKeyVersion = 1; 44 | bool hasOwnerKeyVersion = 2; 45 | } -------------------------------------------------------------------------------- /custom_components/googlefindmy/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "googlefindmy", 3 | "name": "Google Find My Device", 4 | "version": "1.6.2.3", 5 | "config_flow": true, 6 | "diagnostics": true, 7 | "integration_type": "device", 8 | "iot_class": "cloud_polling", 9 | 10 | "documentation": "https://github.com/BSkando/GoogleFindMy-HA", 11 | "issue_tracker": "https://github.com/BSkando/GoogleFindMy-HA/issues", 12 | "codeowners": ["@BSkando"], 13 | "loggers": ["custom_components.googlefindmy"], 14 | 15 | "icon": "mdi:google", 16 | 17 | "dependencies": ["http"], 18 | "after_dependencies": ["recorder"], 19 | 20 | "requirements": [ 21 | "gpsoauth>=1.1.1", 22 | "beautifulsoup4>=4.12.3", 23 | "pyscrypt>=1.6.2", 24 | "cryptography>=43.0.3", 25 | "pycryptodomex>=3.21.0", 26 | "ecdsa>=0.19.0", 27 | "pytz>=2024.2", 28 | "protobuf>=5.28.3", 29 | "httpx>=0.28.0", 30 | "h2>=4.1.0", 31 | "setuptools>=75.6.0", 32 | "aiohttp>=3.11.8", 33 | "http_ece>=1.1.0", 34 | "requests>=2.25.1", 35 | "undetected_chromedriver>=3.5.5", 36 | "selenium>=4.27.1" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/KeyBackup/shared_key_request.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | import binascii 7 | 8 | from custom_components.googlefindmy.NovaApi.util import generate_random_uuid 9 | from custom_components.googlefindmy.ProtoDecoders import DeviceUpdate_pb2 10 | 11 | def get_security_domain_request_url(): 12 | encryption_unlock_request_extras = DeviceUpdate_pb2.EncryptionUnlockRequestExtras() 13 | encryption_unlock_request_extras.operation = 1 14 | encryption_unlock_request_extras.securityDomain.name = "finder_hw" 15 | encryption_unlock_request_extras.securityDomain.unknown = 0 16 | encryption_unlock_request_extras.sessionId = generate_random_uuid() 17 | 18 | # serialize and print as base64 19 | serialized = encryption_unlock_request_extras.SerializeToString() 20 | 21 | scope = "https://accounts.google.com/encryption/unlock/android?kdi=" 22 | 23 | url = scope + binascii.b2a_base64(serialized).decode('utf-8') 24 | return url 25 | 26 | 27 | if __name__ == '__main__': 28 | print(get_security_domain_request_url()) -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/proto/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # firebase-messaging 3 | # https://github.com/sdb9696/firebase-messaging 4 | # 5 | # MIT License 6 | # 7 | # Copyright (c) 2017 Matthieu Lemoine 8 | # Copyright (c) 2023 Steven Beth 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in all 18 | # copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | # SOFTWARE. 27 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/KeyBackup/response_parser.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | import json 7 | 8 | from custom_components.googlefindmy.example_data_provider import get_example_data 9 | 10 | def _transform_to_byte_array(json_object): 11 | byte_array = bytearray(json_object[str(i)] for i in range(len(json_object))) 12 | return byte_array 13 | 14 | 15 | def get_fmdn_shared_key(vault_keys): 16 | json_object = json.loads(vault_keys) 17 | processed_data = {} 18 | 19 | # Iterate through the keys in the JSON object 20 | for key in json_object: 21 | if key == "finder_hw": 22 | json_array = json_object[key] 23 | array_list2 = [] 24 | 25 | # Iterate through the JSON array 26 | for item in json_array: 27 | epoch = item["epoch"] 28 | key_data = item["key"] 29 | 30 | processed_key = _transform_to_byte_array(key_data) 31 | array_list2.append({"epoch": epoch, "key": processed_key}) 32 | 33 | return processed_key 34 | 35 | processed_data[key] = array_list2 36 | 37 | raise Exception("No suitable key found in the vault keys.") 38 | 39 | 40 | if __name__ == '__main__': 41 | vault_keys = get_example_data("sample_vault_keys") 42 | print(get_fmdn_shared_key(vault_keys).hex()) -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # firebase-messaging 3 | # https://github.com/sdb9696/firebase-messaging 4 | # 5 | # MIT License 6 | # 7 | # Copyright (c) 2017 Matthieu Lemoine 8 | # Copyright (c) 2023 Steven Beth 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in all 18 | # copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | # SOFTWARE. 27 | 28 | from .fcmpushclient import FcmPushClient, FcmPushClientConfig, FcmPushClientRunState 29 | from .fcmregister import FcmRegisterConfig 30 | 31 | __all__ = [ 32 | "FcmPushClientConfig", 33 | "FcmPushClient", 34 | "FcmPushClientRunState", 35 | "FcmRegisterConfig", 36 | ] 37 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/grpc_parser.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | import struct 6 | import gzip 7 | import io 8 | 9 | class GrpcParser: 10 | @staticmethod 11 | def extract_grpc_payload(grpc: bytes) -> bytes: 12 | # Defensive guards for gRPC length-prefixed frame: 1 byte flag + 4 bytes length 13 | if not grpc or len(grpc) < 5: 14 | raise ValueError("Invalid GRPC payload (too short for frame header)") 15 | 16 | flag = grpc[0] # 0 = uncompressed, 1 = compressed (gzip by default) 17 | if flag not in (0, 1): 18 | raise ValueError(f"Invalid GRPC payload (bad compressed-flag {flag})") 19 | 20 | length = struct.unpack(">I", grpc[1:5])[0] 21 | if len(grpc) < 5 + length: 22 | raise ValueError(f"Invalid GRPC payload length (expected {length}, got {len(grpc) - 5})") 23 | 24 | # Extract exactly one message frame (unary RPC) 25 | msg = grpc[5:5 + length] 26 | 27 | if flag == 1: 28 | # Compressed frame (gzip) → decompress 29 | try: 30 | with gzip.GzipFile(fileobj=io.BytesIO(msg)) as gz: 31 | return gz.read() 32 | except Exception as e: 33 | raise ValueError(f"Failed to decompress gRPC frame: {e}") 34 | 35 | return msg 36 | 37 | @staticmethod 38 | def construct_grpc(payload: bytes) -> bytes: 39 | # not compressed 40 | compressed = bytes([0]) 41 | length = len(payload) 42 | length_data = struct.pack(">I", length) 43 | return compressed + length_data + payload 44 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/KeyBackup/lskf_hasher.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | import hashlib 6 | from binascii import unhexlify 7 | from concurrent.futures import ProcessPoolExecutor 8 | import time 9 | import pyscrypt 10 | 11 | from custom_components.googlefindmy.example_data_provider import get_example_data 12 | 13 | 14 | def ascii_to_bytes(string): 15 | return string.encode('ascii') 16 | 17 | 18 | def get_lskf_hash(pin: str, salt: bytes) -> bytes: 19 | # Parameters 20 | data_to_hash = ascii_to_bytes(pin) # Convert the string to an ASCII byte array 21 | 22 | log_n_cost = 4096 # CPU/memory cost parameter 23 | block_size = 8 # Block size 24 | parallelization = 1 # Parallelization factor 25 | key_length = 32 # Length of the derived key in bytes 26 | 27 | # Perform Scrypt hashing 28 | hashed = pyscrypt.hash( 29 | password=data_to_hash, 30 | salt=salt, 31 | N=log_n_cost, 32 | r=block_size, 33 | p=parallelization, 34 | dkLen=key_length 35 | ) 36 | 37 | return hashed 38 | 39 | def hash_pin(pin): 40 | sample_pin_salt = unhexlify(get_example_data("sample_pin_salt")) 41 | 42 | hash_object = hashlib.sha256(get_lskf_hash(pin, sample_pin_salt)) 43 | hash_hex = hash_object.hexdigest() 44 | 45 | print(f"PIN: {pin}, Hash: {hash_hex}") 46 | return pin, hash_hex 47 | 48 | 49 | if __name__ == '__main__': 50 | start_time = time.time() 51 | pins = [f"{i:04d}" for i in range(10000)] 52 | 53 | with ProcessPoolExecutor() as executor: 54 | results = list(executor.map(hash_pin, pins)) 55 | 56 | for pin, hashed in results: 57 | print(f"PIN: {pin}, Hash: {hashed}") 58 | 59 | end_time = time.time() 60 | elapsed_time = end_time - start_time 61 | print(f"Time taken: {elapsed_time:.2f} seconds") -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/auth_flow.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | from selenium.webdriver.support.ui import WebDriverWait 7 | from custom_components.googlefindmy.chrome_driver import create_driver 8 | 9 | def request_oauth_account_token_flow(headless=False): 10 | 11 | # In Home Assistant context, skip the interactive prompts 12 | import sys 13 | is_home_assistant = 'homeassistant' in sys.modules 14 | 15 | if not headless and not is_home_assistant: 16 | print("""[AuthFlow] This script will now open Google Chrome on your device to login to your Google account. 17 | > Please make sure that Chrome is installed on your system. 18 | > For macOS users only: Make that you allow Python (or PyCharm) to control Chrome if prompted. 19 | """) 20 | 21 | # Press enter to continue 22 | input("[AuthFlow] Press Enter to continue...") 23 | 24 | # Automatically install and set up the Chrome driver 25 | if not is_home_assistant: 26 | print("[AuthFlow] Installing ChromeDriver...") 27 | 28 | driver = create_driver(headless=headless) 29 | 30 | try: 31 | # Open the browser and navigate to the URL 32 | driver.get("https://accounts.google.com/EmbeddedSetup") 33 | 34 | # Wait until the "oauth_token" cookie is set 35 | if not is_home_assistant: 36 | print("[AuthFlow] Waiting for 'oauth_token' cookie to be set...") 37 | WebDriverWait(driver, 300).until( 38 | lambda d: d.get_cookie("oauth_token") is not None 39 | ) 40 | 41 | # Get the value of the "oauth_token" cookie 42 | oauth_token_cookie = driver.get_cookie("oauth_token") 43 | oauth_token_value = oauth_token_cookie['value'] 44 | 45 | # Print the value of the "oauth_token" cookie 46 | if not is_home_assistant: 47 | print("[AuthFlow] Retrieved Account Token successfully.") 48 | 49 | return oauth_token_value 50 | 51 | finally: 52 | # Close the browser 53 | driver.quit() 54 | 55 | if __name__ == '__main__': 56 | request_oauth_account_token_flow() -------------------------------------------------------------------------------- /custom_components/googlefindmy/FMDNCrypto/eid_generator.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | from Cryptodome.Cipher import AES 6 | from ecdsa import SECP160r1 7 | 8 | from custom_components.googlefindmy.example_data_provider import get_example_data 9 | 10 | # Constants 11 | K = 10 12 | ROTATION_PERIOD = 1024 # 2^K seconds 13 | 14 | def generate_eid(identity_key: bytes, timestamp: int) -> bytes: 15 | # Calculate r 16 | r = calculate_r(identity_key, timestamp) 17 | 18 | # Compute R = r * G 19 | curve = SECP160r1 20 | R = r * curve.generator 21 | 22 | # Return the x coordinate of R as the EID 23 | return R.x().to_bytes(20, 'big') 24 | 25 | 26 | def calculate_r(identity_key: bytes, timestamp: int): 27 | # ts_bytes is the timestamp in bytes, but the least K significant bits are set to 0 28 | ts_bytes = get_masked_timestamp(timestamp, K) 29 | identity_key_bytes = identity_key 30 | 31 | # A random is generated by AES-ECB-256 encrypting the following data structure with the ephemeral identity key: 32 | data = bytearray(32) 33 | data[0:11] = b'\xFF' * 11 34 | data[11] = K 35 | data[12:16] = ts_bytes 36 | data[16:27] = b'\x00' * 11 37 | data[27] = K 38 | data[28:32] = ts_bytes 39 | 40 | # AES-ECB-256 encryption 41 | cipher = AES.new(identity_key_bytes, AES.MODE_ECB) 42 | r_dash = cipher.encrypt(bytes(data)) 43 | 44 | # Convert r' to an integer 45 | r_dash_int = int.from_bytes(r_dash, byteorder='big', signed=False) 46 | 47 | # SECP160R1 parameters 48 | curve = SECP160r1 49 | n = curve.order 50 | 51 | # r' is now projected to the finite field Fp by calculating r = r' mod n 52 | return (r_dash_int % n) 53 | 54 | 55 | def get_masked_timestamp(timestamp: int, K: int): 56 | # Create a bitmask that has all bits set except for the K least significant bits 57 | mask = ~((1 << K) - 1) 58 | 59 | # Zero out the K least significant bits 60 | timestamp &= mask 61 | 62 | # Convert back to a byte array with the same length as the original 63 | return timestamp.to_bytes(4, byteorder='big') 64 | 65 | 66 | if __name__ == '__main__': 67 | 68 | sample_identity_key = get_example_data("sample_identity_key") 69 | 70 | # Generate EIDs 71 | for i in range(1000): 72 | timestamp = i * ROTATION_PERIOD 73 | eid = generate_eid(sample_identity_key, timestamp) 74 | print(f"{timestamp}: {eid.hex()}") -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/const.py: -------------------------------------------------------------------------------- 1 | # 2 | # firebase-messaging 3 | # https://github.com/sdb9696/firebase-messaging 4 | # 5 | # MIT License 6 | # 7 | # Copyright (c) 2017 Matthieu Lemoine 8 | # Copyright (c) 2023 Steven Beth 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in all 18 | # copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | # SOFTWARE. 27 | 28 | """Constants module.""" 29 | 30 | GCM_REGISTER_URL = "https://android.clients.google.com/c2dm/register3" 31 | GCM_CHECKIN_URL = "https://android.clients.google.com/checkin" 32 | GCM_SERVER_KEY_BIN = ( 33 | b"\x04\x33\x94\xf7\xdf\xa1\xeb\xb1\xdc\x03\xa2\x5e\x15\x71\xdb\x48\xd3" 34 | + b"\x2e\xed\xed\xb2\x34\xdb\xb7\x47\x3a\x0c\x8f\xc4\xcc\xe1\x6f\x3c" 35 | + b"\x8c\x84\xdf\xab\xb6\x66\x3e\xf2\x0c\xd4\x8b\xfe\xe3\xf9\x76\x2f" 36 | + b"\x14\x1c\x63\x08\x6a\x6f\x2d\xb1\x1a\x95\xb0\xce\x37\xc0\x9c\x6e" 37 | ) 38 | # urlsafe b64 encoding of the binary key with = padding removed 39 | GCM_SERVER_KEY_B64 = ( 40 | "BDOU99-h67HcA6JeFXHbSNMu7e2yNNu3RzoM" 41 | + "j8TM4W88jITfq7ZmPvIM1Iv-4_l2LxQcYwhqby2xGpWwzjfAnG4" 42 | ) 43 | 44 | FCM_SUBSCRIBE_URL = "https://fcm.googleapis.com/fcm/connect/subscribe/" 45 | FCM_SEND_URL = "https://fcm.googleapis.com/fcm/send/" 46 | 47 | FCM_API = "https://fcm.googleapis.com/v1/" 48 | FCM_REGISTRATION = "https://fcmregistrations.googleapis.com/v1/" 49 | FCM_INSTALLATION = "https://firebaseinstallations.googleapis.com/v1/" 50 | AUTH_VERSION = "FIS_v2" 51 | SDK_VERSION = "w:0.6.6" 52 | 53 | DOORBELLS_ENDPOINT = "/clients_api/doorbots/{0}" 54 | 55 | MCS_VERSION = 41 56 | MCS_HOST = "mtalk.google.com" 57 | MCS_PORT = 5228 58 | MCS_SELECTIVE_ACK_ID = 12 59 | MCS_STREAM_ACK_ID = 13 60 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/ProtoDecoders/Common_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: ProtoDecoders/Common.proto 4 | # Protobuf Python Version: 4.25.3 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | # Create a separate descriptor pool to avoid conflicts 15 | _common_pool = _descriptor_pool.DescriptorPool() 16 | 17 | 18 | 19 | DESCRIPTOR = _common_pool.AddSerializedFile(b'\n\x1aProtoDecoders/Common.proto\"&\n\x04Time\x12\x0f\n\x07seconds\x18\x01 \x01(\r\x12\r\n\x05nanos\x18\x02 \x01(\r\"y\n\x0eLocationReport\x12+\n\x10semanticLocation\x18\x05 \x01(\x0b\x32\x11.SemanticLocation\x12!\n\x0bgeoLocation\x18\n \x01(\x0b\x32\x0c.GeoLocation\x12\x17\n\x06status\x18\x0b \x01(\x0e\x32\x07.Status\"(\n\x10SemanticLocation\x12\x14\n\x0clocationName\x18\x01 \x01(\t\"d\n\x0bGeoLocation\x12)\n\x0f\x65ncryptedReport\x18\x01 \x01(\x0b\x32\x10.EncryptedReport\x12\x18\n\x10\x64\x65viceTimeOffset\x18\x02 \x01(\r\x12\x10\n\x08\x61\x63\x63uracy\x18\x03 \x01(\x02\"Z\n\x0f\x45ncryptedReport\x12\x17\n\x0fpublicKeyRandom\x18\x01 \x01(\x0c\x12\x19\n\x11\x65ncryptedLocation\x18\x02 \x01(\x0c\x12\x13\n\x0bisOwnReport\x18\x03 \x01(\x08\"V\n\x1fGetEidInfoForE2eeDevicesRequest\x12\x17\n\x0fownerKeyVersion\x18\x01 \x01(\x05\x12\x1a\n\x12hasOwnerKeyVersion\x18\x02 \x01(\x08*H\n\x06Status\x12\x0c\n\x08SEMANTIC\x10\x00\x12\x0e\n\nLAST_KNOWN\x10\x01\x12\x10\n\x0c\x43ROWDSOURCED\x10\x02\x12\x0e\n\nAGGREGATED\x10\x03\x62\x06proto3') 20 | 21 | _globals = globals() 22 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 23 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ProtoDecoders.Common_pb2', _globals) 24 | if _descriptor._USE_C_DESCRIPTORS == False: 25 | DESCRIPTOR._options = None 26 | _globals['_STATUS']._serialized_start=517 27 | _globals['_STATUS']._serialized_end=589 28 | _globals['_TIME']._serialized_start=30 29 | _globals['_TIME']._serialized_end=68 30 | _globals['_LOCATIONREPORT']._serialized_start=70 31 | _globals['_LOCATIONREPORT']._serialized_end=191 32 | _globals['_SEMANTICLOCATION']._serialized_start=193 33 | _globals['_SEMANTICLOCATION']._serialized_end=233 34 | _globals['_GEOLOCATION']._serialized_start=235 35 | _globals['_GEOLOCATION']._serialized_end=335 36 | _globals['_ENCRYPTEDREPORT']._serialized_start=337 37 | _globals['_ENCRYPTEDREPORT']._serialized_end=427 38 | _globals['_GETEIDINFOFORE2EEDEVICESREQUEST']._serialized_start=429 39 | _globals['_GETEIDINFOFORE2EEDEVICESREQUEST']._serialized_end=515 40 | # @@protoc_insertion_point(module_scope) 41 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ExecuteAction/PlaySound/sound_request.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | from __future__ import annotations 7 | 8 | from typing import Optional 9 | 10 | from custom_components.googlefindmy.NovaApi.ExecuteAction.nbe_execute_action import ( 11 | create_action_request, 12 | serialize_action_request, 13 | ) 14 | 15 | 16 | def create_sound_request( 17 | should_start: bool, 18 | canonic_device_id: str, 19 | gcm_registration_id: str, 20 | request_uuid: Optional[str] = None, 21 | ) -> str: 22 | """Build the hex-encoded Nova payload for a Play/Stop Sound action (pure builder). 23 | 24 | This function performs **no network I/O**. It only constructs and serializes the 25 | protobuf action request used by Nova. Transport submission is handled elsewhere. 26 | 27 | Args: 28 | should_start: True to start playing a sound, False to stop. 29 | canonic_device_id: Canonical device id (as returned by the device list). 30 | gcm_registration_id: FCM registration/token string used for push correlation. 31 | request_uuid: Optional request UUID; a random one will be generated when omitted. 32 | 33 | Returns: 34 | Hex-encoded protobuf payload suitable for Nova transport. 35 | 36 | Raises: 37 | ValueError: If required arguments are empty or malformed. 38 | """ 39 | # Defensive argument validation (keep server-side errors out of transport path) 40 | if not isinstance(canonic_device_id, str) or not canonic_device_id.strip(): 41 | raise ValueError("canonic_device_id must be a non-empty string") 42 | if not isinstance(gcm_registration_id, str) or not gcm_registration_id.strip(): 43 | raise ValueError("gcm_registration_id must be a non-empty string") 44 | 45 | # Lazy import of protobuf module to avoid heavy import work at integration startup. 46 | from custom_components.googlefindmy.ProtoDecoders import DeviceUpdate_pb2 47 | from custom_components.googlefindmy.NovaApi.util import generate_random_uuid 48 | 49 | if request_uuid is None: 50 | request_uuid = generate_random_uuid() 51 | 52 | # Create a base action request envelope 53 | action_request = create_action_request( 54 | canonic_device_id, 55 | gcm_registration_id, 56 | request_uuid=request_uuid, 57 | ) 58 | 59 | # Select action branch; Google’s API currently accepts an unspecified component 60 | # for whole-device sound requests. 61 | if should_start: 62 | action_request.action.startSound.component = ( 63 | DeviceUpdate_pb2.DeviceComponent.DEVICE_COMPONENT_UNSPECIFIED 64 | ) 65 | else: 66 | action_request.action.stopSound.component = ( 67 | DeviceUpdate_pb2.DeviceComponent.DEVICE_COMPONENT_UNSPECIFIED 68 | ) 69 | 70 | # Serialize to hex for Nova transport 71 | return serialize_action_request(action_request) 72 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/KeyBackup/shared_key_flow.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | from selenium.webdriver.support.ui import WebDriverWait 7 | from selenium.webdriver.support import expected_conditions as ec 8 | 9 | from custom_components.googlefindmy.KeyBackup.response_parser import get_fmdn_shared_key 10 | from custom_components.googlefindmy.KeyBackup.shared_key_request import get_security_domain_request_url 11 | from custom_components.googlefindmy.chrome_driver import create_driver 12 | 13 | def request_shared_key_flow(): 14 | driver = create_driver() 15 | try: 16 | # Open Google accounts sign-in page 17 | driver.get("https://accounts.google.com/") 18 | 19 | # Wait for user to sign in and redirect to https://myaccount.google.com 20 | WebDriverWait(driver, 300).until( 21 | ec.url_contains("https://myaccount.google.com") 22 | ) 23 | print("[SharedKeyFlow] Signed in successfully.") 24 | 25 | # Open the security domain request URL 26 | security_url = get_security_domain_request_url() 27 | driver.get(security_url) 28 | 29 | # Inject JavaScript interface 30 | script = """ 31 | window.mm = { 32 | setVaultSharedKeys: function(str, vaultKeys) { 33 | console.log('setVaultSharedKeys called with:', str, vaultKeys); 34 | alert(JSON.stringify({ method: 'setVaultSharedKeys', str: str, vaultKeys: vaultKeys })); 35 | }, 36 | closeView: function() { 37 | console.log('closeView called'); 38 | alert(JSON.stringify({ method: 'closeView' })); 39 | } 40 | }; 41 | """ 42 | driver.execute_script(script) 43 | 44 | while True: 45 | # Check for alerts indicating JavaScript calls 46 | try: 47 | WebDriverWait(driver, 0.5).until(ec.alert_is_present()) 48 | alert = driver.switch_to.alert 49 | message = alert.text 50 | alert.accept() 51 | 52 | # Parse the alert message 53 | import json 54 | data = json.loads(message) 55 | 56 | if data['method'] == 'setVaultSharedKeys': 57 | shared_key = get_fmdn_shared_key(data['vaultKeys']) 58 | print("[SharedKeyFlow] Received Shared Key.") 59 | driver.quit() 60 | return shared_key.hex() 61 | elif data['method'] == 'closeView': 62 | print("[SharedKeyFlow] closeView() called. Closing browser.") 63 | driver.quit() 64 | break 65 | 66 | except Exception: 67 | pass 68 | 69 | except Exception as e: 70 | print(f"An error occurred: {e}") 71 | finally: 72 | driver.quit() 73 | 74 | 75 | if __name__ == "__main__": 76 | request_shared_key_flow() 77 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/proto/android_checkin_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: android_checkin.proto 4 | """Generated protocol buffer code.""" 5 | 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | # Create a separate descriptor pool to avoid conflicts 16 | _firebase_pool = _descriptor_pool.DescriptorPool() 17 | 18 | DESCRIPTOR = _firebase_pool.AddSerializedFile( 19 | b'\n\x15\x61ndroid_checkin.proto\x12\rcheckin_proto"\x8a\x03\n\x10\x43hromeBuildProto\x12:\n\x08platform\x18\x01 \x01(\x0e\x32(.checkin_proto.ChromeBuildProto.Platform\x12\x16\n\x0e\x63hrome_version\x18\x02 \x01(\t\x12\x38\n\x07\x63hannel\x18\x03 \x01(\x0e\x32\'.checkin_proto.ChromeBuildProto.Channel"}\n\x08Platform\x12\x10\n\x0cPLATFORM_WIN\x10\x01\x12\x10\n\x0cPLATFORM_MAC\x10\x02\x12\x12\n\x0ePLATFORM_LINUX\x10\x03\x12\x11\n\rPLATFORM_CROS\x10\x04\x12\x10\n\x0cPLATFORM_IOS\x10\x05\x12\x14\n\x10PLATFORM_ANDROID\x10\x06"i\n\x07\x43hannel\x12\x12\n\x0e\x43HANNEL_STABLE\x10\x01\x12\x10\n\x0c\x43HANNEL_BETA\x10\x02\x12\x0f\n\x0b\x43HANNEL_DEV\x10\x03\x12\x12\n\x0e\x43HANNEL_CANARY\x10\x04\x12\x13\n\x0f\x43HANNEL_UNKNOWN\x10\x05"\xf6\x01\n\x13\x41ndroidCheckinProto\x12\x19\n\x11last_checkin_msec\x18\x02 \x01(\x03\x12\x15\n\rcell_operator\x18\x06 \x01(\t\x12\x14\n\x0csim_operator\x18\x07 \x01(\t\x12\x0f\n\x07roaming\x18\x08 \x01(\t\x12\x13\n\x0buser_number\x18\t \x01(\x05\x12:\n\x04type\x18\x0c \x01(\x0e\x32\x19.checkin_proto.DeviceType:\x11\x44\x45VICE_ANDROID_OS\x12\x35\n\x0c\x63hrome_build\x18\r \x01(\x0b\x32\x1f.checkin_proto.ChromeBuildProto*g\n\nDeviceType\x12\x15\n\x11\x44\x45VICE_ANDROID_OS\x10\x01\x12\x11\n\rDEVICE_IOS_OS\x10\x02\x12\x19\n\x15\x44\x45VICE_CHROME_BROWSER\x10\x03\x12\x14\n\x10\x44\x45VICE_CHROME_OS\x10\x04\x42\x02H\x03' 20 | ) 21 | 22 | _globals = globals() 23 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 24 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "android_checkin_pb2", _globals) 25 | if _descriptor._USE_C_DESCRIPTORS == False: 26 | _globals["DESCRIPTOR"]._options = None 27 | _globals["DESCRIPTOR"]._serialized_options = b"H\003" 28 | _globals["_DEVICETYPE"]._serialized_start = 686 29 | _globals["_DEVICETYPE"]._serialized_end = 789 30 | _globals["_CHROMEBUILDPROTO"]._serialized_start = 41 31 | _globals["_CHROMEBUILDPROTO"]._serialized_end = 435 32 | _globals["_CHROMEBUILDPROTO_PLATFORM"]._serialized_start = 203 33 | _globals["_CHROMEBUILDPROTO_PLATFORM"]._serialized_end = 328 34 | _globals["_CHROMEBUILDPROTO_CHANNEL"]._serialized_start = 330 35 | _globals["_CHROMEBUILDPROTO_CHANNEL"]._serialized_end = 435 36 | _globals["_ANDROIDCHECKINPROTO"]._serialized_start = 438 37 | _globals["_ANDROIDCHECKINPROTO"]._serialized_end = 684 38 | # @@protoc_insertion_point(module_scope) 39 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/ProtoDecoders/LocationReportsUpload_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: ProtoDecoders/LocationReportsUpload.proto 4 | # Protobuf Python Version: 4.25.3 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | # Import Common_pb2 first to ensure it's loaded before we create our descriptor 15 | from custom_components.googlefindmy.ProtoDecoders import Common_pb2 as ProtoDecoders_dot_Common__pb2 16 | 17 | # Use the same descriptor pool as Common_pb2 to maintain dependencies 18 | try: 19 | # Try to access Common's descriptor pool 20 | _findmy_pool = ProtoDecoders_dot_Common__pb2._common_pool 21 | except AttributeError: 22 | # Fallback to creating our own pool if Common doesn't have _common_pool 23 | _findmy_pool = _descriptor_pool.DescriptorPool() 24 | 25 | DESCRIPTOR = _findmy_pool.AddSerializedFile(b'\n)ProtoDecoders/LocationReportsUpload.proto\x1a\x1aProtoDecoders/Common.proto\"|\n\x15LocationReportsUpload\x12\x18\n\x07reports\x18\x01 \x03(\x0b\x32\x07.Report\x12\'\n\x0e\x63lientMetadata\x18\x02 \x01(\x0b\x32\x0f.ClientMetadata\x12\x0f\n\x07random1\x18\x03 \x01(\x04\x12\x0f\n\x07random2\x18\x04 \x01(\x04\"g\n\x06Report\x12%\n\radvertisement\x18\x01 \x01(\x0b\x32\x0e.Advertisement\x12\x13\n\x04time\x18\x04 \x01(\x0b\x32\x05.Time\x12!\n\x08location\x18\x06 \x01(\x0b\x32\x0f.LocationReport\"U\n\rAdvertisement\x12\x1f\n\nidentifier\x18\x05 \x01(\x0b\x32\x0b.Identifier\x12#\n\x1bunwantedTrackingModeEnabled\x18\x06 \x01(\r\";\n\nIdentifier\x12\x14\n\x0ctruncatedEid\x18\x06 \x01(\x0c\x12\x17\n\x0f\x63\x61nonicDeviceId\x18\x07 \x01(\x0c\"<\n\x0e\x43lientMetadata\x12*\n\x07version\x18\x02 \x01(\x0b\x32\x19.ClientVersionInformation\"7\n\x18\x43lientVersionInformation\x12\x1b\n\x13playServicesVersion\x18\x01 \x01(\tb\x06proto3') 26 | 27 | _globals = globals() 28 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 29 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ProtoDecoders.LocationReportsUpload_pb2', _globals) 30 | if _descriptor._USE_C_DESCRIPTORS == False: 31 | DESCRIPTOR._options = None 32 | _globals['_LOCATIONREPORTSUPLOAD']._serialized_start=73 33 | _globals['_LOCATIONREPORTSUPLOAD']._serialized_end=197 34 | _globals['_REPORT']._serialized_start=199 35 | _globals['_REPORT']._serialized_end=302 36 | _globals['_ADVERTISEMENT']._serialized_start=304 37 | _globals['_ADVERTISEMENT']._serialized_end=389 38 | _globals['_IDENTIFIER']._serialized_start=391 39 | _globals['_IDENTIFIER']._serialized_end=450 40 | _globals['_CLIENTMETADATA']._serialized_start=452 41 | _globals['_CLIENTMETADATA']._serialized_end=512 42 | _globals['_CLIENTVERSIONINFORMATION']._serialized_start=514 43 | _globals['_CLIENTVERSIONINFORMATION']._serialized_end=569 44 | # @@protoc_insertion_point(module_scope) 45 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/ProtoDecoders/LocationReportsUpload_pb2.pyi: -------------------------------------------------------------------------------- 1 | from custom_components.googlefindmy.ProtoDecoders import Common_pb2 as _Common_pb2 2 | from google.protobuf.internal import containers as _containers 3 | from google.protobuf import descriptor as _descriptor 4 | from google.protobuf import message as _message 5 | from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union 6 | 7 | DESCRIPTOR: _descriptor.FileDescriptor 8 | 9 | class LocationReportsUpload(_message.Message): 10 | __slots__ = ("reports", "clientMetadata", "random1", "random2") 11 | REPORTS_FIELD_NUMBER: _ClassVar[int] 12 | CLIENTMETADATA_FIELD_NUMBER: _ClassVar[int] 13 | RANDOM1_FIELD_NUMBER: _ClassVar[int] 14 | RANDOM2_FIELD_NUMBER: _ClassVar[int] 15 | reports: _containers.RepeatedCompositeFieldContainer[Report] 16 | clientMetadata: ClientMetadata 17 | random1: int 18 | random2: int 19 | def __init__(self, reports: _Optional[_Iterable[_Union[Report, _Mapping]]] = ..., clientMetadata: _Optional[_Union[ClientMetadata, _Mapping]] = ..., random1: _Optional[int] = ..., random2: _Optional[int] = ...) -> None: ... 20 | 21 | class Report(_message.Message): 22 | __slots__ = ("advertisement", "time", "location") 23 | ADVERTISEMENT_FIELD_NUMBER: _ClassVar[int] 24 | TIME_FIELD_NUMBER: _ClassVar[int] 25 | LOCATION_FIELD_NUMBER: _ClassVar[int] 26 | advertisement: Advertisement 27 | time: _Common_pb2.Time 28 | location: _Common_pb2.LocationReport 29 | def __init__(self, advertisement: _Optional[_Union[Advertisement, _Mapping]] = ..., time: _Optional[_Union[_Common_pb2.Time, _Mapping]] = ..., location: _Optional[_Union[_Common_pb2.LocationReport, _Mapping]] = ...) -> None: ... 30 | 31 | class Advertisement(_message.Message): 32 | __slots__ = ("identifier", "unwantedTrackingModeEnabled") 33 | IDENTIFIER_FIELD_NUMBER: _ClassVar[int] 34 | UNWANTEDTRACKINGMODEENABLED_FIELD_NUMBER: _ClassVar[int] 35 | identifier: Identifier 36 | unwantedTrackingModeEnabled: int 37 | def __init__(self, identifier: _Optional[_Union[Identifier, _Mapping]] = ..., unwantedTrackingModeEnabled: _Optional[int] = ...) -> None: ... 38 | 39 | class Identifier(_message.Message): 40 | __slots__ = ("truncatedEid", "canonicDeviceId") 41 | TRUNCATEDEID_FIELD_NUMBER: _ClassVar[int] 42 | CANONICDEVICEID_FIELD_NUMBER: _ClassVar[int] 43 | truncatedEid: bytes 44 | canonicDeviceId: bytes 45 | def __init__(self, truncatedEid: _Optional[bytes] = ..., canonicDeviceId: _Optional[bytes] = ...) -> None: ... 46 | 47 | class ClientMetadata(_message.Message): 48 | __slots__ = ("version",) 49 | VERSION_FIELD_NUMBER: _ClassVar[int] 50 | version: ClientVersionInformation 51 | def __init__(self, version: _Optional[_Union[ClientVersionInformation, _Mapping]] = ...) -> None: ... 52 | 53 | class ClientVersionInformation(_message.Message): 54 | __slots__ = ("playServicesVersion",) 55 | PLAYSERVICESVERSION_FIELD_NUMBER: _ClassVar[int] 56 | playServicesVersion: str 57 | def __init__(self, playServicesVersion: _Optional[str] = ...) -> None: ... 58 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/proto/checkin_pb2.py: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: checkin.proto 3 | """Generated protocol buffer code.""" 4 | 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import descriptor_pool as _descriptor_pool 7 | from google.protobuf import symbol_database as _symbol_database 8 | from google.protobuf.internal import builder as _builder 9 | 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | # Use the same descriptor pool as android_checkin_pb2 to resolve dependencies 15 | try: 16 | from . import android_checkin_pb2 17 | _firebase_pool = android_checkin_pb2._firebase_pool 18 | except ImportError: 19 | _firebase_pool = _descriptor_pool.DescriptorPool() 20 | 21 | DESCRIPTOR = _firebase_pool.AddSerializedFile( 22 | b'\n\rcheckin.proto\x12\rcheckin_proto\x1a\x15\x61ndroid_checkin.proto"/\n\x10GservicesSetting\x12\x0c\n\x04name\x18\x01 \x02(\x0c\x12\r\n\x05value\x18\x02 \x02(\x0c"\xcb\x03\n\x15\x41ndroidCheckinRequest\x12\x0c\n\x04imei\x18\x01 \x01(\t\x12\x0c\n\x04meid\x18\n \x01(\t\x12\x10\n\x08mac_addr\x18\t \x03(\t\x12\x15\n\rmac_addr_type\x18\x13 \x03(\t\x12\x15\n\rserial_number\x18\x10 \x01(\t\x12\x0b\n\x03\x65sn\x18\x11 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x03\x12\x12\n\nlogging_id\x18\x07 \x01(\x03\x12\x0e\n\x06\x64igest\x18\x03 \x01(\t\x12\x0e\n\x06locale\x18\x06 \x01(\t\x12\x33\n\x07\x63heckin\x18\x04 \x02(\x0b\x32".checkin_proto.AndroidCheckinProto\x12\x15\n\rdesired_build\x18\x05 \x01(\t\x12\x16\n\x0emarket_checkin\x18\x08 \x01(\t\x12\x16\n\x0e\x61\x63\x63ount_cookie\x18\x0b \x03(\t\x12\x11\n\ttime_zone\x18\x0c \x01(\t\x12\x16\n\x0esecurity_token\x18\r \x01(\x06\x12\x0f\n\x07version\x18\x0e \x01(\x05\x12\x10\n\x08ota_cert\x18\x0f \x03(\t\x12\x10\n\x08\x66ragment\x18\x14 \x01(\x05\x12\x11\n\tuser_name\x18\x15 \x01(\t\x12\x1a\n\x12user_serial_number\x18\x16 \x01(\x05"\x83\x02\n\x16\x41ndroidCheckinResponse\x12\x10\n\x08stats_ok\x18\x01 \x02(\x08\x12\x11\n\ttime_msec\x18\x03 \x01(\x03\x12\x0e\n\x06\x64igest\x18\x04 \x01(\t\x12\x15\n\rsettings_diff\x18\t \x01(\x08\x12\x16\n\x0e\x64\x65lete_setting\x18\n \x03(\t\x12\x30\n\x07setting\x18\x05 \x03(\x0b\x32\x1f.checkin_proto.GservicesSetting\x12\x11\n\tmarket_ok\x18\x06 \x01(\x08\x12\x12\n\nandroid_id\x18\x07 \x01(\x06\x12\x16\n\x0esecurity_token\x18\x08 \x01(\x06\x12\x14\n\x0cversion_info\x18\x0b \x01(\tB\x02H\x03' 23 | ) 24 | 25 | _globals = globals() 26 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 27 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "checkin_pb2", _globals) 28 | if _descriptor._USE_C_DESCRIPTORS == False: 29 | _globals["DESCRIPTOR"]._options = None 30 | _globals["DESCRIPTOR"]._serialized_options = b"H\003" 31 | _globals["_GSERVICESSETTING"]._serialized_start = 55 32 | _globals["_GSERVICESSETTING"]._serialized_end = 102 33 | _globals["_ANDROIDCHECKINREQUEST"]._serialized_start = 105 34 | _globals["_ANDROIDCHECKINREQUEST"]._serialized_end = 564 35 | _globals["_ANDROIDCHECKINRESPONSE"]._serialized_start = 567 36 | _globals["_ANDROIDCHECKINRESPONSE"]._serialized_end = 826 37 | # @@protoc_insertion_point(module_scope) 38 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/UploadPrecomputedPublicKeyIds/upload_precomputed_public_key_ids.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | import time 6 | 7 | from custom_components.googlefindmy.FMDNCrypto.eid_generator import ROTATION_PERIOD, generate_eid 8 | from custom_components.googlefindmy.NovaApi.ExecuteAction.LocateTracker.decrypt_locations import retrieve_identity_key, is_mcu_tracker 9 | from custom_components.googlefindmy.ProtoDecoders.DeviceUpdate_pb2 import DevicesList, UploadPrecomputedPublicKeyIdsRequest, PublicKeyIdList 10 | from custom_components.googlefindmy.SpotApi.CreateBleDevice.config import max_truncated_eid_seconds_server 11 | from custom_components.googlefindmy.SpotApi.CreateBleDevice.util import hours_to_seconds 12 | from custom_components.googlefindmy.SpotApi.spot_request import spot_request 13 | 14 | 15 | def refresh_custom_trackers(device_list: DevicesList): 16 | 17 | request = UploadPrecomputedPublicKeyIdsRequest() 18 | needs_upload = False 19 | 20 | for device in device_list.deviceMetadata: 21 | 22 | # This is a microcontroller 23 | if is_mcu_tracker(device.information.deviceRegistration): 24 | 25 | needs_upload = True 26 | 27 | new_truncated_ids = UploadPrecomputedPublicKeyIdsRequest.DevicePublicKeyIds() 28 | new_truncated_ids.pairDate = device.information.deviceRegistration.pairDate 29 | new_truncated_ids.canonicId.id = device.identifierInformation.canonicIds.canonicId[0].id 30 | 31 | identity_key = retrieve_identity_key(device.information.deviceRegistration) 32 | next_eids = get_next_eids(identity_key, new_truncated_ids.pairDate, int(time.time() - hours_to_seconds(3)), duration_seconds=max_truncated_eid_seconds_server) 33 | 34 | for next_eid in next_eids: 35 | new_truncated_ids.clientList.publicKeyIdInfo.append(next_eid) 36 | 37 | request.deviceEids.append(new_truncated_ids) 38 | 39 | if needs_upload: 40 | print("[UploadPrecomputedPublicKeyIds] Updating your registered µC devices...") 41 | try: 42 | bytes_data = request.SerializeToString() 43 | spot_request("UploadPrecomputedPublicKeyIds", bytes_data) 44 | except Exception as e: 45 | print(f"[UploadPrecomputedPublicKeyIds] Failed to refresh custom trackers. Please file a bug report. Continuing... {str(e)}") 46 | 47 | 48 | def get_next_eids(eik: bytes, pair_date: int, start_date: int, duration_seconds: int) -> list[PublicKeyIdList.PublicKeyIdInfo]: 49 | duration_seconds = int(duration_seconds) 50 | public_key_id_list = [] 51 | 52 | start_offset = start_date - pair_date 53 | current_time_offset = start_offset - (start_offset % ROTATION_PERIOD) 54 | 55 | static_eid = generate_eid(eik, 0) 56 | 57 | while current_time_offset <= start_offset + duration_seconds: 58 | time = pair_date + current_time_offset 59 | 60 | info = PublicKeyIdList.PublicKeyIdInfo() 61 | info.timestamp.seconds = time 62 | info.publicKeyId.truncatedEid = static_eid[:10] 63 | 64 | public_key_id_list.append(info) 65 | 66 | current_time_offset += 1024 67 | 68 | return public_key_id_list -------------------------------------------------------------------------------- /custom_components/googlefindmy/ProtoDecoders/Common_pb2.pyi: -------------------------------------------------------------------------------- 1 | from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper 2 | from google.protobuf import descriptor as _descriptor 3 | from google.protobuf import message as _message 4 | from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union 5 | 6 | DESCRIPTOR: _descriptor.FileDescriptor 7 | 8 | class Status(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): 9 | __slots__ = () 10 | SEMANTIC: _ClassVar[Status] 11 | LAST_KNOWN: _ClassVar[Status] 12 | CROWDSOURCED: _ClassVar[Status] 13 | AGGREGATED: _ClassVar[Status] 14 | SEMANTIC: Status 15 | LAST_KNOWN: Status 16 | CROWDSOURCED: Status 17 | AGGREGATED: Status 18 | 19 | class Time(_message.Message): 20 | __slots__ = ("seconds", "nanos") 21 | SECONDS_FIELD_NUMBER: _ClassVar[int] 22 | NANOS_FIELD_NUMBER: _ClassVar[int] 23 | seconds: int 24 | nanos: int 25 | def __init__(self, seconds: _Optional[int] = ..., nanos: _Optional[int] = ...) -> None: ... 26 | 27 | class LocationReport(_message.Message): 28 | __slots__ = ("semanticLocation", "geoLocation", "status") 29 | SEMANTICLOCATION_FIELD_NUMBER: _ClassVar[int] 30 | GEOLOCATION_FIELD_NUMBER: _ClassVar[int] 31 | STATUS_FIELD_NUMBER: _ClassVar[int] 32 | semanticLocation: SemanticLocation 33 | geoLocation: GeoLocation 34 | status: Status 35 | def __init__(self, semanticLocation: _Optional[_Union[SemanticLocation, _Mapping]] = ..., geoLocation: _Optional[_Union[GeoLocation, _Mapping]] = ..., status: _Optional[_Union[Status, str]] = ...) -> None: ... 36 | 37 | class SemanticLocation(_message.Message): 38 | __slots__ = ("locationName",) 39 | LOCATIONNAME_FIELD_NUMBER: _ClassVar[int] 40 | locationName: str 41 | def __init__(self, locationName: _Optional[str] = ...) -> None: ... 42 | 43 | class GeoLocation(_message.Message): 44 | __slots__ = ("encryptedReport", "deviceTimeOffset", "accuracy") 45 | ENCRYPTEDREPORT_FIELD_NUMBER: _ClassVar[int] 46 | DEVICETIMEOFFSET_FIELD_NUMBER: _ClassVar[int] 47 | ACCURACY_FIELD_NUMBER: _ClassVar[int] 48 | encryptedReport: EncryptedReport 49 | deviceTimeOffset: int 50 | accuracy: float 51 | def __init__(self, encryptedReport: _Optional[_Union[EncryptedReport, _Mapping]] = ..., deviceTimeOffset: _Optional[int] = ..., accuracy: _Optional[float] = ...) -> None: ... 52 | 53 | class EncryptedReport(_message.Message): 54 | __slots__ = ("publicKeyRandom", "encryptedLocation", "isOwnReport") 55 | PUBLICKEYRANDOM_FIELD_NUMBER: _ClassVar[int] 56 | ENCRYPTEDLOCATION_FIELD_NUMBER: _ClassVar[int] 57 | ISOWNREPORT_FIELD_NUMBER: _ClassVar[int] 58 | publicKeyRandom: bytes 59 | encryptedLocation: bytes 60 | isOwnReport: bool 61 | def __init__(self, publicKeyRandom: _Optional[bytes] = ..., encryptedLocation: _Optional[bytes] = ..., isOwnReport: bool = ...) -> None: ... 62 | 63 | class GetEidInfoForE2eeDevicesRequest(_message.Message): 64 | __slots__ = ("ownerKeyVersion", "hasOwnerKeyVersion") 65 | OWNERKEYVERSION_FIELD_NUMBER: _ClassVar[int] 66 | HASOWNERKEYVERSION_FIELD_NUMBER: _ClassVar[int] 67 | ownerKeyVersion: int 68 | hasOwnerKeyVersion: bool 69 | def __init__(self, ownerKeyVersion: _Optional[int] = ..., hasOwnerKeyVersion: bool = ...) -> None: ... 70 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/services.yaml: -------------------------------------------------------------------------------- 1 | locate_device: 2 | name: Locate Device 3 | description: "Get the current location of a Google Find My device" 4 | # Note: no 'target' block — we use an explicit 'device_id' field for compatibility with our resolver. 5 | fields: 6 | device_id: 7 | name: Device 8 | description: "Select a Google Find My device (or pass a canonical ID via Developer Tools)" 9 | required: true 10 | selector: 11 | device: 12 | integration: googlefindmy 13 | 14 | play_sound: 15 | name: Play Sound 16 | description: "Play a sound on a Google Find My device" 17 | # Note: no 'target' block — we use an explicit 'device_id' field for compatibility with our resolver. 18 | fields: 19 | device_id: 20 | name: Device 21 | description: "Select a Google Find My device (or pass a canonical ID via Developer Tools)" 22 | required: true 23 | selector: 24 | device: 25 | integration: googlefindmy 26 | 27 | stop_sound: 28 | name: Stop Sound 29 | description: "Stop the sound on a Google Find My device" 30 | # Note: no 'target' block — we use an explicit 'device_id' field for compatibility with our resolver. 31 | fields: 32 | device_id: 33 | name: Device 34 | description: "Select a Google Find My device (or pass a canonical ID via Developer Tools)" 35 | required: true 36 | selector: 37 | device: 38 | integration: googlefindmy 39 | 40 | locate_device_external: 41 | name: Locate Device (External) 42 | description: "Get current location using an external GoogleFindMyTools helper (workaround for FCM issues)" 43 | fields: 44 | device_id: 45 | name: Device 46 | description: "Select a Google Find My device (or pass a canonical ID via Developer Tools)" 47 | required: true 48 | selector: 49 | device: 50 | integration: googlefindmy 51 | device_name: 52 | name: Device Name 53 | description: "Optional friendly name used only for logs" 54 | example: "Keys Tracker" 55 | required: false 56 | selector: 57 | text: 58 | 59 | refresh_device_urls: 60 | name: Refresh Device URLs 61 | description: "Recompute and update each device's configuration_url to point to the map view (use after base-URL or token policy changes)" 62 | 63 | rebuild_registry: 64 | name: Rebuild / Migrate Registry 65 | description: "Maintenance: run a soft data→options migration or rebuild entities/devices for this integration." 66 | fields: 67 | mode: 68 | name: Mode 69 | description: "Choose 'Rebuild' to remove entities/devices and reload; 'Migrate' only re-applies the soft data→options migration." 70 | advanced: true 71 | required: false 72 | default: rebuild 73 | selector: 74 | select: 75 | options: 76 | - label: Rebuild (remove + reload) 77 | value: rebuild 78 | - label: Migrate (data→options only) 79 | value: migrate 80 | device_ids: 81 | name: Devices (optional) 82 | description: "Limit the operation to specific devices. Leave empty to target all Google Find My devices. (This selector passes HA device IDs.)" 83 | advanced: true 84 | required: false 85 | selector: 86 | device: 87 | multiple: true 88 | integration: googlefindmy 89 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ExecuteAction/nbe_execute_action.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | from __future__ import annotations 7 | 8 | import binascii 9 | from typing import Any, Optional 10 | 11 | from custom_components.googlefindmy.NovaApi.util import generate_random_uuid 12 | 13 | # Session-stable client UUID (created lazily to avoid import-time side effects) 14 | _CLIENT_UUID: Optional[str] = None 15 | 16 | 17 | def _get_client_uuid() -> str: 18 | """Return a session-stable client UUID, generating it on first use.""" 19 | global _CLIENT_UUID 20 | if not _CLIENT_UUID: 21 | _CLIENT_UUID = generate_random_uuid() 22 | return _CLIENT_UUID 23 | 24 | 25 | def create_action_request( 26 | canonic_device_id: str, 27 | gcm_registration_id: str, 28 | *, 29 | request_uuid: Optional[str] = None, 30 | fmd_client_uuid: Optional[str] = None, 31 | ) -> Any: 32 | """Build an ExecuteActionRequest protobuf for Nova (pure builder, no I/O). 33 | 34 | Args: 35 | canonic_device_id: Canonical device id from the device list. 36 | gcm_registration_id: FCM registration token (used for push correlation). 37 | request_uuid: Optional request UUID. If omitted, a random UUID is generated. 38 | fmd_client_uuid: Optional session/client UUID. If omitted, a lazy, session-stable 39 | UUID is used. 40 | 41 | Returns: 42 | A `DeviceUpdate_pb2.ExecuteActionRequest` instance. 43 | 44 | Raises: 45 | ValueError: If required arguments are missing/empty. 46 | """ 47 | if not isinstance(canonic_device_id, str) or not canonic_device_id.strip(): 48 | raise ValueError("canonic_device_id must be a non-empty string") 49 | if not isinstance(gcm_registration_id, str) or not gcm_registration_id.strip(): 50 | raise ValueError("gcm_registration_id must be a non-empty string") 51 | 52 | # Lazy import to avoid heavy protobuf import work at HA startup time. 53 | from custom_components.googlefindmy.ProtoDecoders import DeviceUpdate_pb2 54 | 55 | req_uuid = request_uuid or generate_random_uuid() 56 | client_uuid = fmd_client_uuid or _get_client_uuid() 57 | 58 | action_request = DeviceUpdate_pb2.ExecuteActionRequest() 59 | 60 | # Scope: SPOT device by canonical id 61 | action_request.scope.type = DeviceUpdate_pb2.DeviceType.SPOT_DEVICE 62 | action_request.scope.device.canonicId.id = canonic_device_id 63 | 64 | # Request metadata (types mirror scope) 65 | action_request.requestMetadata.type = DeviceUpdate_pb2.DeviceType.SPOT_DEVICE 66 | action_request.requestMetadata.requestUuid = req_uuid 67 | action_request.requestMetadata.fmdClientUuid = client_uuid 68 | action_request.requestMetadata.gcmRegistrationId.id = gcm_registration_id 69 | 70 | # Historical flag observed in upstream traffic; kept for parity/back-compat. 71 | action_request.requestMetadata.unknown = True # noqa: FBT003 (explicit protocol quirk) 72 | 73 | return action_request 74 | 75 | 76 | def serialize_action_request(action_request: Any) -> str: 77 | """Serialize an ExecuteActionRequest to hex for Nova transport.""" 78 | # Serialize to bytes 79 | binary_payload = action_request.SerializeToString() 80 | # Encode as hex for Nova HTTP transport 81 | return binascii.hexlify(binary_payload).decode("utf-8") 82 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/chrome_driver.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | import undetected_chromedriver as uc 7 | import os 8 | import shutil 9 | import platform 10 | 11 | def find_chrome(): 12 | """Find Chrome executable using known paths and system commands.""" 13 | possiblePaths = [ 14 | r"C:\Program Files\Google\Chrome\Application\chrome.exe", 15 | r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", 16 | r"C:\ProgramData\chocolatey\bin\chrome.exe", 17 | r"C:\Users\%USERNAME%\AppData\Local\Google\Chrome\Application\chrome.exe", 18 | "/usr/bin/google-chrome", 19 | "/usr/local/bin/google-chrome", 20 | "/opt/google/chrome/chrome", 21 | "/snap/bin/chromium", 22 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 23 | ] 24 | 25 | # Check predefined paths 26 | for path in possiblePaths: 27 | if os.path.exists(path): 28 | return path 29 | 30 | # Use system command to find Chrome 31 | try: 32 | if platform.system() == "Windows": 33 | chrome_path = shutil.which("chrome") 34 | else: 35 | chrome_path = shutil.which("google-chrome") or shutil.which("chromium") 36 | if chrome_path: 37 | return chrome_path 38 | except Exception as e: 39 | print(f"[ChromeDriver] Error while searching system paths: {e}") 40 | 41 | return None 42 | 43 | 44 | def get_options(headless=False): 45 | chrome_options = uc.ChromeOptions() 46 | if not headless: 47 | chrome_options.add_argument("--start-maximized") 48 | else: 49 | chrome_options.add_argument("--headless") 50 | chrome_options.add_argument("--disable-extensions") 51 | chrome_options.add_argument("--disable-gpu") 52 | chrome_options.add_argument("--no-sandbox") 53 | 54 | return chrome_options 55 | 56 | 57 | def create_driver(headless=False): 58 | """Create a Chrome WebDriver with undetected_chromedriver.""" 59 | 60 | try: 61 | chrome_options = get_options(headless=headless) 62 | driver = uc.Chrome(options=chrome_options) 63 | print("[ChromeDriver] Installed and browser started.") 64 | return driver 65 | except Exception: 66 | print("[ChromeDriver] Default ChromeDriver creation failed. Trying alternative paths...") 67 | 68 | chrome_path = find_chrome() 69 | if chrome_path: 70 | chrome_options = get_options(headless=headless) 71 | chrome_options.binary_location = chrome_path 72 | try: 73 | driver = uc.Chrome(options=chrome_options) 74 | print(f"[ChromeDriver] ChromeDriver started using {chrome_path}") 75 | return driver 76 | except Exception as e: 77 | print(f"[ChromeDriver] ChromeDriver failed using path {chrome_path}: {e}") 78 | else: 79 | print("[ChromeDriver] No Chrome executable found in known paths.") 80 | 81 | raise Exception( 82 | "[ChromeDriver] Failed to install ChromeDriver. A current version of Chrome was not detected on your system.\n" 83 | "If you know that Chrome is installed, update Chrome to the latest version. If the script is still not working, " 84 | "set the path to your Chrome executable manually inside the script." 85 | ) 86 | 87 | 88 | if __name__ == '__main__': 89 | create_driver() -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/proto/android_checkin.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Chromium Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | // 5 | // Logging information for Android "checkin" events (automatic, periodic 6 | // requests made by Android devices to the server). 7 | 8 | syntax = "proto2"; 9 | 10 | option optimize_for = LITE_RUNTIME; 11 | package checkin_proto; 12 | 13 | // Build characteristics unique to the Chrome browser, and Chrome OS 14 | message ChromeBuildProto { 15 | enum Platform { 16 | PLATFORM_WIN = 1; 17 | PLATFORM_MAC = 2; 18 | PLATFORM_LINUX = 3; 19 | PLATFORM_CROS = 4; 20 | PLATFORM_IOS = 5; 21 | // Just a placeholder. Likely don't need it due to the presence of the 22 | // Android GCM on phone/tablet devices. 23 | PLATFORM_ANDROID = 6; 24 | } 25 | 26 | enum Channel { 27 | CHANNEL_STABLE = 1; 28 | CHANNEL_BETA = 2; 29 | CHANNEL_DEV = 3; 30 | CHANNEL_CANARY = 4; 31 | CHANNEL_UNKNOWN = 5; // for tip of tree or custom builds 32 | } 33 | 34 | // The platform of the device. 35 | optional Platform platform = 1; 36 | 37 | // The Chrome instance's version. 38 | optional string chrome_version = 2; 39 | 40 | // The Channel (build type) of Chrome. 41 | optional Channel channel = 3; 42 | } 43 | 44 | // Information sent by the device in a "checkin" request. 45 | message AndroidCheckinProto { 46 | // Miliseconds since the Unix epoch of the device's last successful checkin. 47 | optional int64 last_checkin_msec = 2; 48 | 49 | // The current MCC+MNC of the mobile device's current cell. 50 | optional string cell_operator = 6; 51 | 52 | // The MCC+MNC of the SIM card (different from operator if the 53 | // device is roaming, for instance). 54 | optional string sim_operator = 7; 55 | 56 | // The device's current roaming state (reported starting in eclair builds). 57 | // Currently one of "{,not}mobile-{,not}roaming", if it is present at all. 58 | optional string roaming = 8; 59 | 60 | // For devices supporting multiple user profiles (which may be 61 | // supported starting in jellybean), the ordinal number of the 62 | // profile that is checking in. This is 0 for the primary profile 63 | // (which can't be changed without wiping the device), and 1,2,3,... 64 | // for additional profiles (which can be added and deleted freely). 65 | optional int32 user_number = 9; 66 | 67 | // Class of device. Indicates the type of build proto 68 | // (IosBuildProto/ChromeBuildProto/AndroidBuildProto) 69 | // That is included in this proto 70 | optional DeviceType type = 12 [default = DEVICE_ANDROID_OS]; 71 | 72 | // For devices running MCS on Chrome, build-specific characteristics 73 | // of the browser. There are no hardware aspects (except for ChromeOS). 74 | // This will only be populated for Chrome builds/ChromeOS devices 75 | optional checkin_proto.ChromeBuildProto chrome_build = 13; 76 | 77 | // Note: Some of the Android specific optional fields were skipped to limit 78 | // the protobuf definition. 79 | // Next 14 80 | } 81 | 82 | // enum values correspond to the type of device. 83 | // Used in the AndroidCheckinProto and Device proto. 84 | enum DeviceType { 85 | // Android Device 86 | DEVICE_ANDROID_OS = 1; 87 | 88 | // Apple IOS device 89 | DEVICE_IOS_OS = 2; 90 | 91 | // Chrome browser - Not Chrome OS. No hardware records. 92 | DEVICE_CHROME_BROWSER = 3; 93 | 94 | // Chrome OS 95 | DEVICE_CHROME_OS = 4; 96 | } 97 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Google Find My Device Authentication", 6 | "description": "Choose your authentication method. Method 1 is recommended if you can run GoogleFindMyTools on a computer with Chrome.", 7 | "data": { 8 | "auth_method": "Authentication Method" 9 | } 10 | }, 11 | "secrets_json": { 12 | "title": "Method 1: GoogleFindMyTools secrets.json", 13 | "description": "Run GoogleFindMyTools on a machine with Chrome to generate authentication tokens, then paste the complete contents of the Auth/secrets.json file below.", 14 | "data": { 15 | "secrets_json": "Contents of secrets.json file" 16 | } 17 | }, 18 | "individual_tokens": { 19 | "title": "Method 2: Individual OAuth Token", 20 | "description": "Enter your OAuth token and Google email address obtained from a manual authentication process.", 21 | "data": { 22 | "oauth_token": "OAuth Token", 23 | "google_email": "Google Email Address" 24 | } 25 | } 26 | }, 27 | "error": { 28 | "auth_failed": "Authentication failed. Please try again.", 29 | "invalid_token": "Invalid token provided or missing required fields.", 30 | "invalid_json": "Invalid JSON format in secrets.json content.", 31 | "no_devices": "No devices found. Please ensure you have Find My Device enabled on your Google account.", 32 | "cannot_connect": "Failed to connect to Google API. This may be a temporary issue - please try again.", 33 | "unknown": "Unexpected error occurred." 34 | }, 35 | "abort": { 36 | "already_configured": "Google Find My Device is already configured.", 37 | "cannot_connect": "Failed to connect to Google Find My Device." 38 | } 39 | }, 40 | "options": { 41 | "step": { 42 | "init": { 43 | "title": "Configure Google Find My Device", 44 | "description": "Configuring account: {account_email}\n\nTo add another account, use '+ Add Integration' from the Integrations page.", 45 | "menu_options": { 46 | "settings": "Settings", 47 | "credentials": "Update Credentials", 48 | "visibility": "Device Visibility" 49 | }, 50 | "data": { 51 | "map_view_token_expiration": "Enable map view token expiration" 52 | }, 53 | "data_description": { 54 | "map_view_token_expiration": "When enabled, map view tokens expire after 1 week. When disabled (default), tokens do not expire." 55 | } 56 | }, 57 | "credentials": { 58 | "title": "Update Credentials for {account_email}", 59 | "description": "⚠️ This will update credentials for the current account only.\n\nTo add a different account, cancel this and use '+ Add Integration' from the Integrations page instead.", 60 | "data": { 61 | "new_secrets_json": "New secrets.json content (optional)", 62 | "new_oauth_token": "New OAuth Token (optional)", 63 | "new_google_email": "New Google Email (optional)" 64 | } 65 | } 66 | } 67 | }, 68 | "services": { 69 | "locate_device": { 70 | "name": "Locate Device", 71 | "description": "Get the current location of a Google Find My device.", 72 | "fields": { 73 | "device_id": { 74 | "name": "Device ID", 75 | "description": "The ID of the device to locate." 76 | } 77 | } 78 | }, 79 | "play_sound": { 80 | "name": "Play Sound", 81 | "description": "Play a sound on a Google Find My device.", 82 | "fields": { 83 | "device_id": { 84 | "name": "Device ID", 85 | "description": "The ID of the device to play sound on." 86 | } 87 | } 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/CreateBleDevice/create_ble_device.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | import secrets 7 | import time 8 | 9 | from custom_components.googlefindmy.FMDNCrypto.key_derivation import FMDNOwnerOperations 10 | from custom_components.googlefindmy.FMDNCrypto.eid_generator import ROTATION_PERIOD, generate_eid 11 | from custom_components.googlefindmy.KeyBackup.cloud_key_decryptor import encrypt_aes_gcm 12 | from custom_components.googlefindmy.ProtoDecoders.DeviceUpdate_pb2 import DeviceComponentInformation, SpotDeviceType, RegisterBleDeviceRequest, PublicKeyIdList 13 | from custom_components.googlefindmy.SpotApi.CreateBleDevice.config import mcu_fast_pair_model_id, max_truncated_eid_seconds_server 14 | from custom_components.googlefindmy.SpotApi.CreateBleDevice.util import flip_bits 15 | from custom_components.googlefindmy.SpotApi.GetEidInfoForE2eeDevices.get_owner_key import get_owner_key 16 | from custom_components.googlefindmy.SpotApi.spot_request import spot_request 17 | 18 | 19 | def register_esp32(): 20 | 21 | owner_key = get_owner_key() 22 | 23 | eik = secrets.token_bytes(32) 24 | eid = generate_eid(eik, 0) 25 | pair_date = int(time.time()) 26 | 27 | register_request = RegisterBleDeviceRequest() 28 | register_request.fastPairModelId = mcu_fast_pair_model_id 29 | 30 | # Description 31 | register_request.description.userDefinedName = "GoogleFindMyTools µC" 32 | register_request.description.deviceType = SpotDeviceType.DEVICE_TYPE_BEACON 33 | 34 | # Device Components Information 35 | component_information = DeviceComponentInformation() 36 | component_information.imageUrl = "https://docs.espressif.com/projects/esp-idf/en/v4.3/esp32/_images/esp32-DevKitM-1-isometric.png" 37 | register_request.description.deviceComponentsInformation.append(component_information) 38 | 39 | # Capabilities 40 | register_request.capabilities.isAdvertising = True 41 | register_request.capabilities.trackableComponents = 1 42 | register_request.capabilities.capableComponents = 1 43 | 44 | # E2EE Registration 45 | register_request.e2eePublicKeyRegistration.rotationExponent = 10 46 | register_request.e2eePublicKeyRegistration.pairingDate = pair_date 47 | 48 | # Encrypted User Secrets 49 | # Flip bits so Android devices cannot decrypt the key 50 | register_request.e2eePublicKeyRegistration.encryptedUserSecrets.encryptedIdentityKey = flip_bits(encrypt_aes_gcm(owner_key, eik), True) 51 | 52 | # Random keys, not used for ESP 53 | register_request.e2eePublicKeyRegistration.encryptedUserSecrets.encryptedAccountKey = secrets.token_bytes(44) 54 | register_request.e2eePublicKeyRegistration.encryptedUserSecrets.encryptedSha256AccountKeyPublicAddress = secrets.token_bytes(60) 55 | 56 | register_request.e2eePublicKeyRegistration.encryptedUserSecrets.ownerKeyVersion = 1 57 | register_request.e2eePublicKeyRegistration.encryptedUserSecrets.creationDate.seconds = pair_date 58 | 59 | time_counter = pair_date 60 | truncated_eid = eid[:10] 61 | 62 | # announce advertisements 63 | for _ in range(int(max_truncated_eid_seconds_server / ROTATION_PERIOD)): 64 | pub_key_id = PublicKeyIdList.PublicKeyIdInfo() 65 | pub_key_id.publicKeyId.truncatedEid = truncated_eid 66 | pub_key_id.timestamp.seconds = time_counter 67 | register_request.e2eePublicKeyRegistration.publicKeyIdList.publicKeyIdInfo.append(pub_key_id) 68 | 69 | time_counter += ROTATION_PERIOD 70 | 71 | # General 72 | register_request.manufacturerName = "GoogleFindMyTools" 73 | register_request.modelName = "µC" 74 | 75 | ownerKeys = FMDNOwnerOperations() 76 | ownerKeys.generate_keys(identity_key=eik) 77 | 78 | register_request.ringKey = ownerKeys.ringing_key 79 | register_request.recoveryKey = ownerKeys.recovery_key 80 | register_request.unwantedTrackingKey = ownerKeys.tracking_key 81 | 82 | bytes_data = register_request.SerializeToString() 83 | spot_request("CreateBleDevice", bytes_data) 84 | 85 | print("Registered device successfully. Copy the Advertisement Key below. It will not be shown again.") 86 | print("Afterward, go to the folder 'GoogleFindMyTools/ESP32Firmware' or 'GoogleFindMyTools/ZephyrFirmware' and follow the instructions in the README.md file.") 87 | 88 | print("+" + "-" * 78 + "+") 89 | print("|" + " " * 19 + eid.hex() + " " * 19 + "|") 90 | print("|" + " " * 30 + "Advertisement Key" + " " * 31 + "|") 91 | print("+" + "-" * 78 + "+") -------------------------------------------------------------------------------- /custom_components/googlefindmy/tests/test_diagnostics.py: -------------------------------------------------------------------------------- 1 | # tests/test_diagnostics.py 2 | """Diagnostics tests for Google Find My Device. 3 | 4 | Validates: 5 | - No secrets/PII are exposed (tokens/emails/IDs redacted or omitted) 6 | - Options snapshot contains only counts and safe booleans/ints 7 | - Integration metadata present 8 | - Registry counts are computed 9 | - Coordinator snapshot shape is stable and safe 10 | """ 11 | from __future__ import annotations 12 | 13 | from typing import Any, Dict 14 | 15 | import pytest 16 | from homeassistant.core import HomeAssistant 17 | 18 | try: 19 | from tests.common import MockConfigEntry # type: ignore 20 | except Exception: 21 | from pytest_homeassistant_custom_component.common import MockConfigEntry # type: ignore 22 | 23 | DOMAIN = "googlefindmy" 24 | CONF_OAUTH_TOKEN = "oauth_token" 25 | CONF_GOOGLE_EMAIL = "google_email" 26 | 27 | # Import the function under test 28 | from custom_components.googlefindmy.diagnostics import ( # noqa: E402 29 | async_get_config_entry_diagnostics, 30 | ) 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_diagnostics_basic_shape_and_privacy(hass: HomeAssistant, device_registry, entity_registry) -> None: 35 | """Diagnostics should include safe metadata, config snapshot, registry + coordinator counters.""" 36 | # Create a config entry with options set; data contains secrets (must not leak) 37 | entry = MockConfigEntry( 38 | domain=DOMAIN, 39 | data={CONF_GOOGLE_EMAIL: "user@example.com", CONF_OAUTH_TOKEN: "S" * 64}, 40 | options={ 41 | "tracked_devices": ["dev1", "dev2"], 42 | "location_poll_interval": 120, 43 | "device_poll_delay": 3, 44 | "min_accuracy_threshold": 80, 45 | "movement_threshold": 25, 46 | "google_home_filter_enabled": True, 47 | "google_home_filter_keywords": "nest, mini , speaker", 48 | "enable_stats_entities": True, 49 | "map_view_token_expiration": True, 50 | }, 51 | title="Google Find My Device", 52 | unique_id=f"{DOMAIN}:user@example.com", 53 | ) 54 | entry.add_to_hass(hass) 55 | 56 | # Simulate a minimal coordinator object stored in hass.data 57 | class _Coordinator: 58 | _is_polling = True 59 | _last_poll_mono = 1.0 # some monotonic origin 60 | stats = {"polled": 5, "timeouts": 0} 61 | _device_names = {"dev1": "Phone", "dev2": "Watch"} 62 | _device_location_data = {"dev1": object(), "dev2": object(), "dev3": object()} 63 | 64 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = _Coordinator() 65 | 66 | # Add devices/entities linked to this entry to test registry counts 67 | dev = device_registry.async_get_or_create( 68 | config_entry_id=entry.entry_id, identifiers={(DOMAIN, "dev1")} 69 | ) 70 | entity_registry.async_get_or_create( 71 | domain="sensor", 72 | platform=DOMAIN, 73 | unique_id="sensor.dev1.last_seen", 74 | config_entry=entry, 75 | device_id=dev.id, 76 | ) 77 | 78 | result: Dict[str, Any] = await async_get_config_entry_diagnostics(hass, entry) 79 | 80 | # Top-level structure 81 | assert "entry" in result and "config" in result and "integration" in result 82 | assert "registry" in result and "coordinator" in result 83 | 84 | # Entry section must not include unique_id/email/token 85 | assert result["entry"]["domain"] == DOMAIN 86 | assert "unique_id" not in result["entry"] 87 | assert CONF_GOOGLE_EMAIL not in str(result) 88 | assert CONF_OAUTH_TOKEN not in str(result) 89 | 90 | # Config snapshot: counts and booleans only, keywords as count 91 | cfg = result["config"] 92 | assert cfg["tracked_devices_count"] == 2 93 | assert cfg["google_home_filter_keywords_count"] == 3 94 | assert isinstance(cfg["location_poll_interval"], int) 95 | assert isinstance(cfg["enable_stats_entities"], bool) 96 | 97 | # Registry section contains counts 98 | reg = result["registry"] 99 | assert reg["device_count"] >= 1 100 | assert reg["entity_count"] >= 1 101 | 102 | # Coordinator section has safe numeric stats and flags 103 | coord = result["coordinator"] 104 | assert coord["is_polling"] is True 105 | assert "known_devices_count" in coord and "cache_items_count" in coord 106 | assert isinstance(coord.get("last_poll_wall_ts"), (int, float, type(None))) 107 | # stats are numeric-like or sanitized 108 | assert isinstance(coord["stats"].get("polled"), int) 109 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ExecuteAction/PlaySound/start_sound_request.py: -------------------------------------------------------------------------------- 1 | # custom_components/googlefindmy/NovaApi/ExecuteAction/PlaySound/start_sound_request.py 2 | # 3 | # GoogleFindMyTools - Tools to interact with the Google Find My API 4 | # Copyright © 2024 Leon Böttger. 5 | # 6 | from __future__ import annotations 7 | 8 | import asyncio 9 | from typing import Optional 10 | 11 | import aiohttp 12 | from aiohttp import ClientSession 13 | 14 | from custom_components.googlefindmy.NovaApi.ExecuteAction.PlaySound.sound_request import ( 15 | create_sound_request, 16 | ) 17 | from custom_components.googlefindmy.NovaApi.nova_request import ( 18 | async_nova_request, 19 | NovaAuthError, 20 | NovaRateLimitError, 21 | NovaHTTPError, 22 | ) 23 | from custom_components.googlefindmy.NovaApi.scopes import NOVA_ACTION_API_SCOPE 24 | from custom_components.googlefindmy.NovaApi.util import generate_random_uuid 25 | from custom_components.googlefindmy.example_data_provider import get_example_data 26 | 27 | 28 | def start_sound_request(canonic_device_id: str, gcm_registration_id: str) -> tuple[str, str]: 29 | """Build the hex payload for a 'Play Sound' action (pure builder). 30 | 31 | This function performs no network I/O. It exists for backwards 32 | compatibility with code paths that submit the payload themselves. 33 | 34 | Args: 35 | canonic_device_id: The canonical ID of the target device. 36 | gcm_registration_id: The FCM registration token for push notifications. 37 | 38 | Returns: 39 | A tuple of (hex_payload, request_uuid) where: 40 | - hex_payload: Hex-encoded protobuf payload for Nova transport 41 | - request_uuid: The UUID used for this request (needed to cancel it later) 42 | """ 43 | request_uuid = generate_random_uuid() 44 | hex_payload = create_sound_request(True, canonic_device_id, gcm_registration_id, request_uuid) 45 | return (hex_payload, request_uuid) 46 | 47 | 48 | async def async_submit_start_sound_request( 49 | canonic_device_id: str, 50 | gcm_registration_id: str, 51 | *, 52 | session: Optional[ClientSession] = None, 53 | ) -> Optional[tuple[str, str]]: 54 | """Submit a 'Play Sound' action using the shared async Nova client. 55 | 56 | This function handles the network request and robustly catches common API 57 | and network errors, returning None in those cases to prevent crashes. 58 | 59 | Args: 60 | canonic_device_id: The canonical ID of the target device. 61 | gcm_registration_id: The FCM registration token for push notifications. 62 | session: (Deprecated) The aiohttp ClientSession. No longer used as the 63 | nova client handles session management internally. 64 | 65 | Returns: 66 | A tuple of (response_hex, request_uuid) on success, or None on any handled error. 67 | The request_uuid should be stored and used when calling Stop Sound to properly 68 | cancel this specific Play Sound request. 69 | """ 70 | hex_payload, request_uuid = start_sound_request(canonic_device_id, gcm_registration_id) 71 | try: 72 | # The async Nova client manages session reuse internally; do not pass session through. 73 | response_hex = await async_nova_request(NOVA_ACTION_API_SCOPE, hex_payload) 74 | return (response_hex, request_uuid) if response_hex is not None else None 75 | except asyncio.CancelledError: 76 | raise 77 | except NovaRateLimitError as e: 78 | # transient; caller should treat as soft-fail 79 | return None 80 | except NovaHTTPError as e: 81 | # transient server-side; caller should treat as soft-fail 82 | return None 83 | except NovaAuthError as e: 84 | # auth required; caller may trigger re-auth UX 85 | return None 86 | except aiohttp.ClientError: 87 | # local/network problem 88 | return None 89 | 90 | 91 | if __name__ == "__main__": 92 | # CLI helper (non-HA): obtain a token synchronously and submit via asyncio once. 93 | async def _main(): 94 | """Run a test execution of the start sound request for development.""" 95 | from custom_components.googlefindmy.Auth.fcm_receiver import FcmReceiver # sync-only CLI variant 96 | 97 | sample_canonic_device_id = get_example_data("sample_canonic_device_id") 98 | fcm_token = FcmReceiver().register_for_location_updates(lambda x: None) 99 | 100 | await async_submit_start_sound_request(sample_canonic_device_id, fcm_token) 101 | 102 | asyncio.run(_main()) 103 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ExecuteAction/PlaySound/stop_sound_request.py: -------------------------------------------------------------------------------- 1 | # custom_components/googlefindmy/NovaApi/ExecuteAction/PlaySound/stop_sound_request.py 2 | # 3 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 4 | # Copyright © 2024 Leon Böttger. All rights reserved. 5 | # 6 | """Handles sending a 'Stop Sound' command for a Google Find My Device.""" 7 | from __future__ import annotations 8 | 9 | import asyncio 10 | from typing import Optional 11 | 12 | import aiohttp 13 | from aiohttp import ClientSession 14 | 15 | from custom_components.googlefindmy.NovaApi.ExecuteAction.PlaySound.sound_request import ( 16 | create_sound_request, 17 | ) 18 | from custom_components.googlefindmy.NovaApi.nova_request import ( 19 | async_nova_request, 20 | NovaAuthError, 21 | NovaRateLimitError, 22 | NovaHTTPError, 23 | ) 24 | from custom_components.googlefindmy.NovaApi.scopes import NOVA_ACTION_API_SCOPE 25 | from custom_components.googlefindmy.example_data_provider import get_example_data 26 | 27 | 28 | def stop_sound_request(canonic_device_id: str, gcm_registration_id: str, request_uuid: Optional[str] = None) -> str: 29 | """Build the hex payload for a 'Stop Sound' action (pure builder). 30 | 31 | This function performs no network I/O. It creates the serialized protobuf 32 | message required to stop a sound on a device. 33 | 34 | Args: 35 | canonic_device_id: The canonical ID of the target device. 36 | gcm_registration_id: The FCM registration token for push notifications. 37 | request_uuid: Optional UUID to cancel a specific Play Sound request. 38 | If not provided, a new UUID is generated (may not properly cancel the sound). 39 | 40 | Returns: 41 | Hex-encoded protobuf payload for Nova transport. 42 | """ 43 | return create_sound_request(False, canonic_device_id, gcm_registration_id, request_uuid) 44 | 45 | 46 | async def async_submit_stop_sound_request( 47 | canonic_device_id: str, 48 | gcm_registration_id: str, 49 | *, 50 | request_uuid: Optional[str] = None, 51 | session: Optional[ClientSession] = None, 52 | ) -> Optional[str]: 53 | """Submit a 'Stop Sound' action using the shared async Nova client. 54 | 55 | This function handles the network request and robustly catches common API 56 | and network errors, returning None in those cases to prevent crashes. 57 | 58 | Args: 59 | canonic_device_id: The canonical ID of the target device. 60 | gcm_registration_id: The FCM registration token for push notifications. 61 | request_uuid: Optional UUID to cancel a specific Play Sound request. 62 | If not provided, a new UUID is generated (may not properly cancel the sound). 63 | session: (Deprecated) The aiohttp ClientSession. No longer used as the 64 | nova client handles session management internally. 65 | 66 | Returns: 67 | A hex string of the response payload on success (can be empty), 68 | or None on any handled error (e.g., auth, rate-limit, server error, 69 | or network issues). 70 | """ 71 | hex_payload = stop_sound_request(canonic_device_id, gcm_registration_id, request_uuid) 72 | try: 73 | # The async Nova client manages session reuse internally; do not pass session through. 74 | return await async_nova_request(NOVA_ACTION_API_SCOPE, hex_payload) 75 | except asyncio.CancelledError: 76 | raise 77 | except NovaRateLimitError: 78 | # transient; caller should treat as soft-fail 79 | return None 80 | except NovaHTTPError: 81 | # transient server-side; caller should treat as soft-fail 82 | return None 83 | except NovaAuthError: 84 | # auth required; caller may trigger re-auth UX 85 | return None 86 | except aiohttp.ClientError: 87 | # local/network problem 88 | return None 89 | 90 | 91 | if __name__ == "__main__": 92 | # This block serves as a CLI helper for standalone testing and development. 93 | # It obtains an FCM token synchronously and then runs the async submission 94 | # function in a new event loop. 95 | async def _main(): 96 | """Run a test execution of the stop sound request for development.""" 97 | from custom_components.googlefindmy.Auth.fcm_receiver import FcmReceiver # sync-only CLI variant 98 | 99 | sample_canonic_device_id = get_example_data("sample_canonic_device_id") 100 | fcm_token = FcmReceiver().register_for_location_updates(lambda x: None) 101 | 102 | await async_submit_stop_sound_request(sample_canonic_device_id, fcm_token) 103 | 104 | asyncio.run(_main()) 105 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/binary_sensor.py: -------------------------------------------------------------------------------- 1 | # custom_components/googlefindmy/binary_sensor.py 2 | """Binary sensor entities for Google Find My Device integration.""" 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.components.binary_sensor import ( 9 | BinarySensorEntity, 10 | BinarySensorEntityDescription, 11 | ) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant, callback 14 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 17 | from homeassistant.helpers import device_registry as dr # Needed for DeviceEntryType 18 | 19 | from .const import DOMAIN, INTEGRATION_VERSION 20 | from .coordinator import GoogleFindMyCoordinator 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | POLLING_DESC = BinarySensorEntityDescription( 25 | key="polling", 26 | translation_key="polling", 27 | icon="mdi:refresh", # Default icon 28 | ) 29 | 30 | 31 | async def async_setup_entry( 32 | hass: HomeAssistant, 33 | entry: ConfigEntry, 34 | async_add_entities: AddEntitiesCallback, 35 | ) -> None: 36 | """Set up Google Find My Device binary sensor entities. 37 | 38 | We expose a single diagnostic sensor that reflects whether a polling cycle 39 | is currently in progress. This is helpful for troubleshooting. 40 | """ 41 | coordinator: GoogleFindMyCoordinator = hass.data[DOMAIN][entry.entry_id] 42 | 43 | entities: list[GoogleFindMyPollingSensor] = [GoogleFindMyPollingSensor(coordinator)] 44 | 45 | # Write state immediately so the dashboard reflects the current status 46 | async_add_entities(entities, True) 47 | 48 | 49 | class GoogleFindMyPollingSensor(CoordinatorEntity, BinarySensorEntity): 50 | """Binary sensor indicating whether background polling is active.""" 51 | 52 | _attr_has_entity_name = True # Compose " " 53 | _attr_entity_category = EntityCategory.DIAGNOSTIC 54 | entity_description = POLLING_DESC 55 | 56 | def __init__(self, coordinator: GoogleFindMyCoordinator) -> None: 57 | """Initialize the sensor.""" 58 | super().__init__(coordinator) 59 | # Include entry_id for multi-account support 60 | entry_id = coordinator.config_entry.entry_id if coordinator.config_entry else "default" 61 | self._attr_unique_id = f"{DOMAIN}_{entry_id}_polling" 62 | # _attr_name is intentionally not set; it's derived from translation_key. 63 | 64 | @property 65 | def is_on(self) -> bool: 66 | """Return True if a polling cycle is currently running. 67 | 68 | Prefer the public read-only property 'is_polling' (new Coordinator API). 69 | Fall back to the legacy private attribute '_is_polling' for backward 70 | compatibility. 71 | """ 72 | # Public API (preferred) 73 | public_val = getattr(self.coordinator, "is_polling", None) 74 | if isinstance(public_val, bool): 75 | return public_val 76 | 77 | # Legacy fallback (for compatibility during transition) 78 | legacy_val = bool(getattr(self.coordinator, "_is_polling", False)) 79 | return legacy_val 80 | 81 | @property 82 | def icon(self) -> str: 83 | """Return a dynamic icon reflecting the state.""" 84 | return "mdi:sync" if self.is_on else "mdi:sync-off" 85 | 86 | @property 87 | def device_info(self) -> DeviceInfo: 88 | """Return DeviceInfo for the integration's diagnostic device.""" 89 | # Include entry_id for multi-account support 90 | entry_id = self.coordinator.config_entry.entry_id if self.coordinator.config_entry else "default" 91 | # Get account email for better naming 92 | google_email = "Unknown" 93 | if self.coordinator.config_entry: 94 | google_email = self.coordinator.config_entry.data.get("google_email", "Unknown") 95 | 96 | return DeviceInfo( 97 | identifiers={(DOMAIN, f"integration_{entry_id}")}, 98 | name=f"Google Find My Integration ({google_email})", 99 | manufacturer="BSkando", 100 | model="Find My Device Integration", 101 | sw_version=INTEGRATION_VERSION, # Display integration version 102 | configuration_url="https://github.com/BSkando/GoogleFindMy-HA", 103 | # Mark as a service device to hide the "Delete device" action in HA UI. 104 | entry_type=dr.DeviceEntryType.SERVICE, 105 | ) 106 | 107 | @callback 108 | def _handle_coordinator_update(self) -> None: 109 | """Write state on coordinator updates (polling status can change).""" 110 | self.async_write_ha_state() 111 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/spot_token_retrieval.py: -------------------------------------------------------------------------------- 1 | # custom_components/googlefindmy/Auth/spot_token_retrieval.py 2 | # 3 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 4 | # Copyright © 2024 Leon Böttger. All rights reserved. 5 | # 6 | """Spot token retrieval (async-first, HA-friendly). 7 | 8 | Primary API: 9 | - async_get_spot_token(username: Optional[str] = None) -> str 10 | 11 | Design: 12 | - Async-first: obtains the Google username from the async username provider 13 | when not supplied, then uses the entry-scoped TokenCache to "get or set" 14 | a cached Spot token for that user. 15 | - Token generation prefers an async token retriever if present 16 | (`async_request_token`). If only a sync `request_token` is available, 17 | it is offloaded via `asyncio.to_thread` to avoid blocking the event loop. 18 | - A guarded sync wrapper (`get_spot_token`) is provided for CLI/tests and will 19 | raise if called from within a running event loop. 20 | 21 | Caching: 22 | - Cache key: f"spot_token_{username}" 23 | 24 | This module deliberately avoids heavy imports at module load. Token retrieval 25 | functions are imported lazily inside helpers. 26 | """ 27 | 28 | from __future__ import annotations 29 | 30 | import asyncio 31 | import logging 32 | from typing import Optional 33 | 34 | from .username_provider import async_get_username 35 | from .token_cache import async_get_cached_value_or_set 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | async def _async_generate_spot_token(username: str) -> str: 41 | """Generate a fresh Spot token for `username` without blocking the loop. 42 | 43 | Prefers an async retriever if available; falls back to running the sync 44 | retriever inside a worker thread. 45 | """ 46 | try: 47 | # Prefer native async implementation if available. 48 | from .token_retrieval import async_request_token # type: ignore[attr-defined] 49 | 50 | _LOGGER.debug("Using async_request_token for Spot token generation") 51 | token = await async_request_token(username, "spot", True) 52 | if not token: 53 | raise RuntimeError("async_request_token returned empty token") 54 | return token 55 | except ImportError: 56 | # No async entrypoint exported; fall back to sync retriever in a thread. 57 | _LOGGER.debug("async_request_token not available; falling back to sync retriever in a thread") 58 | from .token_retrieval import request_token # sync path 59 | 60 | token = await asyncio.to_thread(request_token, username, "spot", True) 61 | if not token: 62 | raise RuntimeError("request_token returned empty token") 63 | return token 64 | 65 | 66 | async def async_get_spot_token(username: Optional[str] = None) -> str: 67 | """Return a Spot token for the given user (async, cached). 68 | 69 | Behavior: 70 | - If `username` is None, resolve it via the async username provider. 71 | - Use the entry-scoped TokenCache to return a cached token when present. 72 | - Otherwise, generate a token and store it via the cache's async get-or-set. 73 | 74 | Raises: 75 | RuntimeError: if the username cannot be determined or token retrieval fails. 76 | """ 77 | if not username: 78 | username = await async_get_username() 79 | if not isinstance(username, str) or not username: 80 | raise RuntimeError("Google username is not configured; cannot obtain Spot token") 81 | 82 | cache_key = f"spot_token_{username}" 83 | 84 | async def _generator() -> str: 85 | return await _async_generate_spot_token(username) 86 | 87 | token = await async_get_cached_value_or_set(cache_key, _generator) 88 | if not isinstance(token, str) or not token: 89 | raise RuntimeError("Spot token retrieval produced an invalid value") 90 | return token 91 | 92 | 93 | # ----------------------- Legacy sync wrapper (CLI/tests) ----------------------- 94 | 95 | def get_spot_token(username: Optional[str] = None) -> str: 96 | """Sync wrapper for CLI/tests. 97 | 98 | IMPORTANT: 99 | - Must NOT be called from inside the Home Assistant event loop. 100 | - Prefer `await async_get_spot_token(...)` in all HA code paths. 101 | """ 102 | try: 103 | loop = asyncio.get_running_loop() 104 | if loop.is_running(): 105 | raise RuntimeError( 106 | "get_spot_token() was called inside the event loop. " 107 | "Use `await async_get_spot_token(...)` instead." 108 | ) 109 | except RuntimeError: 110 | # No running loop -> safe to spin a private loop for CLI/tests. 111 | pass 112 | 113 | return asyncio.run(async_get_spot_token(username)) 114 | 115 | 116 | if __name__ == "__main__": 117 | # Simple CLI smoke test (requires cache + username to be initialized by the environment) 118 | try: 119 | print(get_spot_token()) 120 | except Exception as exc: # pragma: no cover 121 | _LOGGER.error("CLI Spot token retrieval failed: %s", exc) 122 | raise 123 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/username_provider.py: -------------------------------------------------------------------------------- 1 | # custom_components/googlefindmy/Auth/username_provider.py 2 | # 3 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 4 | # Copyright © 2024 Leon Böttger. All rights reserved. 5 | # 6 | """Username provider for the Google Find My Device integration. 7 | 8 | This module exposes a single well-known cache key (`username_string`) and a 9 | minimal API to read/write the configured Google account e-mail. 10 | 11 | Design: 12 | - Async-first: `async_get_username` / `async_set_username` are the primary API. 13 | - Legacy sync wrappers are provided for backward compatibility but will raise 14 | a RuntimeError if called from within the Home Assistant event loop to prevent 15 | deadlocks. They remain safe for use from worker threads only. 16 | 17 | The underlying persistence is handled by the entry-scoped TokenCache (HA Store). 18 | """ 19 | 20 | from __future__ import annotations 21 | 22 | import asyncio 23 | import logging 24 | from typing import Optional 25 | 26 | from .token_cache import ( 27 | async_get_cached_value, 28 | async_set_cached_value, 29 | # Legacy sync facades (safe only outside the event loop) 30 | get_cached_value as _legacy_get_cached_value, 31 | set_cached_value as _legacy_set_cached_value, 32 | ) 33 | 34 | _LOGGER = logging.getLogger(__name__) 35 | 36 | # Single well-known cache key for the Google account e-mail 37 | username_string = "username" 38 | 39 | 40 | async def async_get_username() -> Optional[str]: 41 | """Return the configured Google account e-mail from the async token cache. 42 | 43 | Returns: 44 | The username (e-mail) if present and a string, otherwise ``None``. 45 | """ 46 | val = await async_get_cached_value(username_string) 47 | return str(val) if isinstance(val, str) else None 48 | 49 | 50 | async def async_set_username(username: str) -> None: 51 | """Seed or update the username in the async token cache. 52 | 53 | Args: 54 | username: The Google account e-mail to persist. 55 | 56 | Raises: 57 | ValueError: If the provided username is empty or not a string. 58 | """ 59 | if not isinstance(username, str) or not username: 60 | raise ValueError("Username must be a non-empty string.") 61 | await async_set_cached_value(username_string, username) 62 | 63 | 64 | # ----------------------- Legacy sync wrappers (compat) ----------------------- 65 | 66 | def get_username() -> str: 67 | """Legacy sync getter for the username. 68 | 69 | IMPORTANT: 70 | - Must NOT be called from inside the Home Assistant event loop. 71 | - Prefer `await async_get_username()` instead. 72 | 73 | Returns: 74 | The username (e-mail) string if present. 75 | 76 | Raises: 77 | RuntimeError: If called from the event loop (risk of deadlock) or if the 78 | username is missing in the cache (fail-fast to avoid later API errors). 79 | """ 80 | # Prevent deadlocks: disallow sync access from the HA event loop. 81 | try: 82 | asyncio.get_running_loop() 83 | except RuntimeError: 84 | # No running loop => safe to proceed with legacy sync facade. 85 | pass 86 | else: 87 | raise RuntimeError( 88 | "Sync get_username() called from within the event loop. " 89 | "Use `await async_get_username()` instead." 90 | ) 91 | 92 | username = _legacy_get_cached_value(username_string) 93 | if isinstance(username, str) and username: 94 | return username 95 | 96 | # Fail fast instead of returning a placeholder that would cause PERMISSION_DENIED later. 97 | _LOGGER.error( 98 | "No Google username configured in cache key '%s'. Please configure the account in the UI.", 99 | username_string, 100 | ) 101 | raise RuntimeError( 102 | "Google username is not configured. Open the integration UI and set the account." 103 | ) 104 | 105 | 106 | def set_username(username: str) -> None: 107 | """Legacy sync setter for the username. 108 | 109 | IMPORTANT: 110 | - Must NOT be called from inside the Home Assistant event loop. 111 | - Prefer `await async_set_username(...)` instead. 112 | 113 | Args: 114 | username: The Google account e-mail to persist. 115 | 116 | Raises: 117 | RuntimeError: If called from the event loop (risk of deadlock). 118 | ValueError: If the provided username is invalid. 119 | """ 120 | if not isinstance(username, str) or not username: 121 | raise ValueError("Username must be a non-empty string.") 122 | 123 | try: 124 | asyncio.get_running_loop() 125 | except RuntimeError: 126 | # No running loop => safe to proceed with legacy sync facade. 127 | pass 128 | else: 129 | raise RuntimeError( 130 | "Sync set_username() called from within the event loop. " 131 | "Use `await async_set_username(...)` instead." 132 | ) 133 | 134 | _legacy_set_cached_value(username_string, username) 135 | 136 | 137 | __all__ = [ 138 | "username_string", 139 | "async_get_username", 140 | "async_set_username", 141 | "get_username", 142 | "set_username", 143 | ] 144 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/location_recorder.py: -------------------------------------------------------------------------------- 1 | """Location history using Home Assistant's recorder properly.""" 2 | import logging 3 | import time 4 | from typing import Dict, List, Any, Optional 5 | from datetime import datetime, timedelta 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE 9 | from homeassistant.components.recorder import history, get_instance 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | class LocationRecorder: 14 | """Manage location history using Home Assistant's recorder properly.""" 15 | 16 | def __init__(self, hass: HomeAssistant): 17 | """Initialize location recorder.""" 18 | self.hass = hass 19 | 20 | async def get_location_history(self, entity_id: str, hours: int = 24) -> List[Dict[str, Any]]: 21 | """Get location history from recorder for the last N hours.""" 22 | try: 23 | end_time = datetime.now() 24 | start_time = end_time - timedelta(hours=hours) 25 | 26 | # Use the proper recorder database executor API 27 | recorder_instance = get_instance(self.hass) 28 | history_list = await recorder_instance.async_add_executor_job( 29 | history.get_significant_states, 30 | self.hass, 31 | start_time, 32 | end_time, 33 | [entity_id], 34 | None, # filters 35 | True, # include_start_time_state 36 | True, # significant_changes_only 37 | False, # minimal_response 38 | False # no_attributes 39 | ) 40 | 41 | locations = [] 42 | if entity_id in history_list: 43 | for state in history_list[entity_id]: 44 | if state.state not in ('unknown', 'unavailable', None): 45 | # Extract location from attributes 46 | attrs = state.attributes or {} 47 | if ATTR_LATITUDE in attrs and ATTR_LONGITUDE in attrs: 48 | locations.append({ 49 | 'timestamp': state.last_changed.timestamp(), 50 | 'latitude': attrs.get(ATTR_LATITUDE), 51 | 'longitude': attrs.get(ATTR_LONGITUDE), 52 | 'accuracy': attrs.get('gps_accuracy', attrs.get('accuracy')), 53 | 'is_own_report': attrs.get('is_own_report', False), 54 | 'altitude': attrs.get('altitude'), 55 | 'state': state.state 56 | }) 57 | 58 | # Sort by timestamp (newest first) 59 | locations.sort(key=lambda x: x['timestamp'], reverse=True) 60 | 61 | _LOGGER.debug(f"Retrieved {len(locations)} historical locations from recorder") 62 | return locations 63 | 64 | except Exception as e: 65 | _LOGGER.error(f"Failed to get location history from recorder: {e}") 66 | return [] 67 | 68 | def get_best_location(self, locations: List[Dict[str, Any]]) -> Dict[str, Any]: 69 | """Select the best location from a list of locations.""" 70 | if not locations: 71 | return {} 72 | 73 | current_time = time.time() 74 | 75 | def calculate_score(loc): 76 | """Calculate location score (lower is better).""" 77 | try: 78 | accuracy = loc.get('accuracy', float('inf')) 79 | semantic = loc.get('semantic_name') 80 | if accuracy is None: 81 | if (semantic): 82 | accuracy = float(0) 83 | else: 84 | accuracy = float("inf") 85 | else: 86 | accuracy = float(accuracy) 87 | 88 | age_seconds = current_time - loc.get('timestamp', 0) 89 | 90 | # Age penalty: 1m per 3 minutes 91 | age_penalty = age_seconds / (3 * 60) 92 | 93 | # Heavy penalty for old locations (> 2 hours) 94 | if age_seconds > 2 * 60 * 60: 95 | age_penalty += 100 96 | 97 | # Bonus for own reports 98 | own_report_bonus = -2 if loc.get('is_own_report') else 0 99 | 100 | return accuracy + age_penalty + own_report_bonus 101 | 102 | except (TypeError, ValueError): 103 | return float('inf') 104 | 105 | try: 106 | # Sort by score (best first) 107 | sorted_locations = sorted(locations, key=calculate_score) 108 | best = sorted_locations[0] 109 | 110 | age_minutes = (current_time - best.get('timestamp', 0)) / 60 111 | _LOGGER.debug( 112 | f"Selected best location: accuracy={best.get('accuracy')}m, " 113 | f"age={age_minutes:.1f}min from {len(locations)} options" 114 | ) 115 | 116 | return best 117 | 118 | except Exception as e: 119 | _LOGGER.error(f"Failed to select best location: {e}") 120 | return locations[0] if locations else {} -------------------------------------------------------------------------------- /custom_components/googlefindmy/get_oauth_token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Standalone script to obtain Google OAuth token for Home Assistant integration. 4 | Run this script on a machine with Chrome installed, then copy the token to Home Assistant. 5 | """ 6 | 7 | import sys 8 | import os 9 | 10 | # Add the current directory to path so imports work 11 | current_dir = os.path.dirname(os.path.abspath(__file__)) 12 | sys.path.insert(0, current_dir) 13 | 14 | # Also add parent directories to handle custom_components structure 15 | parent_dir = os.path.dirname(current_dir) 16 | if 'custom_components' in current_dir: 17 | # If we're inside custom_components, add the parent of custom_components 18 | sys.path.insert(0, os.path.dirname(parent_dir)) 19 | 20 | def main(): 21 | """Get OAuth token for Google Find My Device.""" 22 | print("=" * 60) 23 | print("Google Find My Device - OAuth Token Generator") 24 | print("=" * 60) 25 | print() 26 | 27 | try: 28 | # Import required packages directly 29 | import undetected_chromedriver as uc 30 | from selenium.webdriver.support.ui import WebDriverWait 31 | import shutil 32 | import platform 33 | 34 | def find_chrome(): 35 | """Find Chrome executable using known paths and system commands.""" 36 | possiblePaths = [ 37 | r"C:\Program Files\Google\Chrome\Application\chrome.exe", 38 | r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", 39 | r"C:\ProgramData\chocolatey\bin\chrome.exe", 40 | r"C:\Users\%USERNAME%\AppData\Local\Google\Chrome\Application\chrome.exe", 41 | "/usr/bin/google-chrome", 42 | "/usr/local/bin/google-chrome", 43 | "/opt/google/chrome/chrome", 44 | "/snap/bin/chromium", 45 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 46 | ] 47 | 48 | # Check predefined paths 49 | for path in possiblePaths: 50 | if os.path.exists(path): 51 | return path 52 | 53 | # Use system command to find Chrome 54 | try: 55 | if platform.system() == "Windows": 56 | chrome_path = shutil.which("chrome") 57 | else: 58 | chrome_path = shutil.which("google-chrome") or shutil.which("chromium") 59 | if chrome_path: 60 | return chrome_path 61 | except Exception as e: 62 | print(f"Error while searching system paths: {e}") 63 | 64 | return None 65 | 66 | def create_driver(headless=False): 67 | """Create and configure Chrome driver.""" 68 | chrome_executable = find_chrome() 69 | 70 | if chrome_executable is None: 71 | raise Exception("Chrome/Chromium not found. Please install Google Chrome or Chromium.") 72 | 73 | options = uc.ChromeOptions() 74 | options.binary_location = chrome_executable 75 | 76 | if headless: 77 | options.add_argument("--headless") 78 | 79 | # Additional options for better compatibility 80 | options.add_argument("--no-sandbox") 81 | options.add_argument("--disable-dev-shm-usage") 82 | options.add_argument("--disable-gpu") 83 | options.add_argument("--remote-debugging-port=9222") 84 | 85 | return uc.Chrome(options=options) 86 | 87 | print("This script will open Chrome to authenticate with Google.") 88 | print("After logging in, the OAuth token will be displayed.") 89 | print("Press Enter to continue...") 90 | input() 91 | 92 | print("Opening Chrome browser...") 93 | driver = create_driver(headless=False) 94 | 95 | try: 96 | # Open the browser and navigate to the URL 97 | driver.get("https://accounts.google.com/EmbeddedSetup") 98 | 99 | # Wait until the "oauth_token" cookie is set 100 | print("Waiting for authentication... Please complete the login process in the browser.") 101 | WebDriverWait(driver, 300).until( 102 | lambda d: d.get_cookie("oauth_token") is not None 103 | ) 104 | 105 | # Get the value of the "oauth_token" cookie 106 | oauth_token_cookie = driver.get_cookie("oauth_token") 107 | oauth_token_value = oauth_token_cookie['value'] 108 | 109 | if oauth_token_value: 110 | print() 111 | print("=" * 60) 112 | print("SUCCESS! Your OAuth token is:") 113 | print("=" * 60) 114 | print(oauth_token_value) 115 | print("=" * 60) 116 | print() 117 | print("Copy this token and paste it in Home Assistant when") 118 | print("configuring the Google Find My Device integration.") 119 | print("Choose 'Manual Token Entry' as the authentication method.") 120 | print() 121 | print("Press Enter to exit...") 122 | input() 123 | else: 124 | print("Failed to obtain OAuth token.") 125 | sys.exit(1) 126 | 127 | finally: 128 | # Close the browser 129 | driver.quit() 130 | 131 | except ImportError as e: 132 | print(f"Missing required package: {e}") 133 | print() 134 | print("Please install the required packages:") 135 | print("pip install selenium undetected-chromedriver") 136 | sys.exit(1) 137 | 138 | except Exception as e: 139 | print(f"Error: {e}") 140 | print() 141 | print("Make sure you have Chrome installed and try again.") 142 | sys.exit(1) 143 | 144 | if __name__ == "__main__": 145 | main() -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/fcm_receiver.py: -------------------------------------------------------------------------------- 1 | """Backward-compatibility shim for legacy FCM receiver. 2 | 3 | This module used to expose a standalone FCM stack via `FcmReceiver`. The integration 4 | now runs a single, shared HA-managed receiver (`FcmReceiverHA`) that is acquired and 5 | released in `__init__.py`. To avoid spawning a second stack (and to keep imports from 6 | older code paths from breaking), this shim provides a minimal, non-invasive surface. 7 | 8 | Notes 9 | ----- 10 | - This shim does **not** start or own any FCM client. 11 | - It reads already-persisted credentials (token cache) for `get_fcm_token()` / 12 | `get_android_id()`. 13 | - `register_for_location_updates()` is accepted for compatibility but is a no-op; the 14 | new design routes device-scoped callbacks through the shared receiver only. 15 | - `stop_listening()` is a no-op (the shared receiver lifecycle is owned by HA). 16 | 17 | If you still call into this file directly, please migrate to the shared receiver: 18 | `from custom_components.googlefindmy.Auth.fcm_receiver_ha import FcmReceiverHA` 19 | """ 20 | 21 | from __future__ import annotations 22 | 23 | import logging 24 | from typing import Any, Callable, Optional 25 | 26 | try: 27 | # Primary, explicit import path within the integration 28 | from custom_components.googlefindmy.Auth.token_cache import ( 29 | get_cached_value, 30 | set_cached_value, 31 | ) 32 | except Exception as err: # noqa: BLE001 - defensive import for rare packaging layouts 33 | raise ImportError( 34 | "googlefindmy.Auth.fcm_receiver shim could not import token_cache; " 35 | "please ensure the integration is installed correctly." 36 | ) from err 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | 41 | class FcmReceiver: # pragma: no cover - legacy surface kept for compatibility 42 | """Legacy class preserved as a thin adapter. 43 | 44 | Only supports token/ID access; start/stop and registration are no-ops to avoid 45 | interfering with the shared, HA-managed FCM lifecycle. 46 | """ 47 | 48 | def __init__(self) -> None: 49 | # Cache a snapshot of persisted credentials to keep legacy callers functional. 50 | creds = get_cached_value("fcm_credentials") 51 | # Normalize common storage shapes (dict or JSON-serialized dict) 52 | if isinstance(creds, str): 53 | try: 54 | import json 55 | 56 | creds = json.loads(creds) 57 | except json.JSONDecodeError: 58 | # Keep raw string; accessors will handle missing structure gracefully. 59 | pass 60 | self._creds: Any = creds 61 | 62 | # ---------------------------- 63 | # Legacy API (no-op / accessors) 64 | # ---------------------------- 65 | def register_for_location_updates(self, callback: Callable[..., Any]) -> Optional[str]: 66 | """Accept legacy registration but do not attach a callback. 67 | 68 | Rationale: 69 | The new architecture wires device-scoped callbacks through the shared 70 | FcmReceiverHA instance within Home Assistant. Here we simply return the 71 | current token (if any) and log a deprecation note. 72 | 73 | Returns: 74 | The FCM token if available (str), else None. 75 | """ 76 | _LOGGER.debug( 77 | "Legacy FcmReceiver.register_for_location_updates() called. " 78 | "This is a no-op in the new architecture; please migrate to FcmReceiverHA." 79 | ) 80 | return self.get_fcm_token() 81 | 82 | def get_fcm_token(self) -> Optional[str]: 83 | """Return the current FCM token from the persisted credentials, if available.""" 84 | creds = self._creds or get_cached_value("fcm_credentials") 85 | if isinstance(creds, str): 86 | # Late normalization if we were constructed before credentials were JSON. 87 | try: 88 | import json 89 | 90 | creds = json.loads(creds) 91 | except Exception: # noqa: BLE001 - tolerate non-JSON values 92 | pass 93 | 94 | try: 95 | token = creds["fcm"]["registration"]["token"] 96 | if isinstance(token, str) and token: 97 | return token 98 | except Exception: # noqa: BLE001 - tolerate missing keys/shape 99 | return None 100 | return None 101 | 102 | def stop_listening(self) -> None: 103 | """Legacy no-op. 104 | 105 | The shared FCM receiver is owned and stopped by the integration lifecycle 106 | (via `entry.async_on_unload` in `__init__.py`). Stopping from here could 107 | erroneously affect other config entries. 108 | """ 109 | _LOGGER.debug("Legacy FcmReceiver.stop_listening(): no-op in compatibility shim.") 110 | 111 | def get_android_id(self) -> Optional[str]: 112 | """Return the Android ID from persisted credentials, if available.""" 113 | creds = self._creds or get_cached_value("fcm_credentials") 114 | if isinstance(creds, str): 115 | try: 116 | import json 117 | 118 | creds = json.loads(creds) 119 | except Exception: # noqa: BLE001 120 | pass 121 | 122 | try: 123 | aid = creds["gcm"]["android_id"] 124 | return str(aid) if aid is not None else None 125 | except Exception: # noqa: BLE001 126 | return None 127 | 128 | # ---------------------------- 129 | # Legacy setter passthrough (rare) 130 | # ---------------------------- 131 | def _on_credentials_updated(self, creds: Any) -> None: 132 | """Legacy setter kept for callers that push new credentials. 133 | 134 | Stores to the shared token cache; does not touch any FCM client here. 135 | """ 136 | try: 137 | set_cached_value("fcm_credentials", creds) 138 | self._creds = creds 139 | _LOGGER.debug("Legacy FcmReceiver: credentials snapshot updated via shim.") 140 | except Exception as err: # noqa: BLE001 141 | _LOGGER.debug("Legacy FcmReceiver: failed to persist credentials: %s", err) 142 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/proto/checkin.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Chromium Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | // 5 | // Request and reply to the "checkin server" devices poll every few hours. 6 | 7 | syntax = "proto2"; 8 | 9 | option optimize_for = LITE_RUNTIME; 10 | 11 | package checkin_proto; 12 | 13 | import "android_checkin.proto"; 14 | 15 | // A concrete name/value pair sent to the device's Gservices database. 16 | message GservicesSetting { 17 | required bytes name = 1; 18 | required bytes value = 2; 19 | } 20 | 21 | // Devices send this every few hours to tell us how they're doing. 22 | message AndroidCheckinRequest { 23 | // IMEI (used by GSM phones) is sent and stored as 15 decimal 24 | // digits; the 15th is a check digit. 25 | optional string imei = 1; // IMEI, reported but not logged. 26 | 27 | // MEID (used by CDMA phones) is sent and stored as 14 hexadecimal 28 | // digits (no check digit). 29 | optional string meid = 10; // MEID, reported but not logged. 30 | 31 | // MAC address (used by non-phone devices). 12 hexadecimal digits; 32 | // no separators (eg "0016E6513AC2", not "00:16:E6:51:3A:C2"). 33 | repeated string mac_addr = 9; // MAC address, reported but not logged. 34 | 35 | // An array parallel to mac_addr, describing the type of interface. 36 | // Currently accepted values: "wifi", "ethernet", "bluetooth". If 37 | // not present, "wifi" is assumed. 38 | repeated string mac_addr_type = 19; 39 | 40 | // Serial number (a manufacturer-defined unique hardware 41 | // identifier). Alphanumeric, case-insensitive. 42 | optional string serial_number = 16; 43 | 44 | // Older CDMA networks use an ESN (8 hex digits) instead of an MEID. 45 | optional string esn = 17; // ESN, reported but not logged 46 | 47 | optional int64 id = 2; // Android device ID, not logged 48 | optional int64 logging_id = 7; // Pseudonymous logging ID for Sawmill 49 | optional string digest = 3; // Digest of device provisioning, not logged. 50 | optional string locale = 6; // Current locale in standard (xx_XX) format 51 | required AndroidCheckinProto checkin = 4; 52 | 53 | // DEPRECATED, see AndroidCheckinProto.requested_group 54 | optional string desired_build = 5; 55 | 56 | // Blob of data from the Market app to be passed to Market API server 57 | optional string market_checkin = 8; 58 | 59 | // SID cookies of any google accounts stored on the phone. Not logged. 60 | repeated string account_cookie = 11; 61 | 62 | // Time zone. Not currently logged. 63 | optional string time_zone = 12; 64 | 65 | // Security token used to validate the checkin request. 66 | // Required for android IDs issued to Froyo+ devices, not for legacy IDs. 67 | optional fixed64 security_token = 13; 68 | 69 | // Version of checkin protocol. 70 | // 71 | // There are currently two versions: 72 | // 73 | // - version field missing: android IDs are assigned based on 74 | // hardware identifiers. unsecured in the sense that you can 75 | // "unregister" someone's phone by sending a registration request 76 | // with their IMEI/MEID/MAC. 77 | // 78 | // - version=2: android IDs are assigned randomly. The device is 79 | // sent a security token that must be included in all future 80 | // checkins for that android id. 81 | // 82 | // - version=3: same as version 2, but the 'fragment' field is 83 | // provided, and the device understands incremental updates to the 84 | // gservices table (ie, only returning the keys whose values have 85 | // changed.) 86 | // 87 | // (version=1 was skipped to avoid confusion with the "missing" 88 | // version field that is effectively version 1.) 89 | optional int32 version = 14; 90 | 91 | // OTA certs accepted by device (base-64 SHA-1 of cert files). Not 92 | // logged. 93 | repeated string ota_cert = 15; 94 | 95 | // Honeycomb and newer devices send configuration data with their checkin. 96 | // optional DeviceConfigurationProto device_configuration = 18; 97 | 98 | // A single CheckinTask on the device may lead to multiple checkin 99 | // requests if there is too much log data to upload in a single 100 | // request. For version 3 and up, this field will be filled in with 101 | // the number of the request, starting with 0. 102 | optional int32 fragment = 20; 103 | 104 | // For devices supporting multiple users, the name of the current 105 | // profile (they all check in independently, just as if they were 106 | // multiple physical devices). This may not be set, even if the 107 | // device is using multiuser. (checkin.user_number should be set to 108 | // the ordinal of the user.) 109 | optional string user_name = 21; 110 | 111 | // For devices supporting multiple user profiles, the serial number 112 | // for the user checking in. Not logged. May not be set, even if 113 | // the device supportes multiuser. checkin.user_number is the 114 | // ordinal of the user (0, 1, 2, ...), which may be reused if users 115 | // are deleted and re-created. user_serial_number is never reused 116 | // (unless the device is wiped). 117 | optional int32 user_serial_number = 22; 118 | 119 | // NEXT TAG: 23 120 | } 121 | 122 | // The response to the device. 123 | message AndroidCheckinResponse { 124 | required bool stats_ok = 1; // Whether statistics were recorded properly. 125 | optional int64 time_msec = 3; // Time of day from server (Java epoch). 126 | // repeated AndroidIntentProto intent = 2; 127 | 128 | // Provisioning is sent if the request included an obsolete digest. 129 | // 130 | // For version <= 2, 'digest' contains the digest that should be 131 | // sent back to the server on the next checkin, and 'setting' 132 | // contains the entire gservices table (which replaces the entire 133 | // current table on the device). 134 | // 135 | // for version >= 3, 'digest' will be absent. If 'settings_diff' 136 | // is false, then 'setting' contains the entire table, as in version 137 | // 2. If 'settings_diff' is true, then 'delete_setting' contains 138 | // the keys to delete, and 'setting' contains only keys to be added 139 | // or for which the value has changed. All other keys in the 140 | // current table should be left untouched. If 'settings_diff' is 141 | // absent, don't touch the existing gservices table. 142 | // 143 | optional string digest = 4; 144 | optional bool settings_diff = 9; 145 | repeated string delete_setting = 10; 146 | repeated GservicesSetting setting = 5; 147 | 148 | optional bool market_ok = 6; // If Market got the market_checkin data OK. 149 | 150 | optional fixed64 android_id = 7; // From the request, or newly assigned 151 | optional fixed64 security_token = 8; // The associated security token 152 | 153 | optional string version_info = 11; 154 | // NEXT TAG: 12 155 | } 156 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/NovaApi/ListDevices/nbe_list_devices.py: -------------------------------------------------------------------------------- 1 | # custom_components/googlefindmy/NovaApi/ListDevices/nbe_list_devices.py 2 | # 3 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 4 | # Copyright © 2024 Leon Böttger. All rights reserved. 5 | # 6 | """Handles fetching the list of Find My devices from the Nova API.""" 7 | from __future__ import annotations 8 | 9 | import asyncio 10 | import binascii 11 | import logging 12 | from typing import Optional 13 | 14 | from aiohttp import ClientSession 15 | 16 | from custom_components.googlefindmy.NovaApi.nova_request import async_nova_request 17 | from custom_components.googlefindmy.NovaApi.scopes import NOVA_LIST_DEVICES_API_SCOPE 18 | from custom_components.googlefindmy.NovaApi.util import generate_random_uuid 19 | from custom_components.googlefindmy.ProtoDecoders import DeviceUpdate_pb2 20 | from custom_components.googlefindmy.ProtoDecoders.decoder import ( 21 | parse_device_list_protobuf, 22 | get_canonic_ids, 23 | ) 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | def create_device_list_request() -> str: 29 | """Build the protobuf request and return it as a hex string (transport payload). 30 | 31 | This function creates the serialized message needed to request a list of all 32 | Spot-enabled devices from the Nova API. It does not perform any network I/O. 33 | 34 | Returns: 35 | A hex-encoded string representing the serialized protobuf message. 36 | """ 37 | wrapper = DeviceUpdate_pb2.DevicesListRequest() 38 | 39 | # Query for Spot devices only (keeps payload lean). 40 | wrapper.deviceListRequestPayload.type = DeviceUpdate_pb2.DeviceType.SPOT_DEVICE 41 | 42 | # Assign a random UUID as request id to help server-side correlation. 43 | wrapper.deviceListRequestPayload.id = generate_random_uuid() 44 | 45 | # Serialize to bytes and hex-encode for Nova transport. 46 | binary_payload = wrapper.SerializeToString() 47 | hex_payload = binascii.hexlify(binary_payload).decode("utf-8") 48 | return hex_payload 49 | 50 | 51 | async def async_request_device_list( 52 | username: Optional[str] = None, 53 | *, 54 | session: Optional[ClientSession] = None, 55 | cache: Optional[any] = None, 56 | ) -> str: 57 | """Asynchronously request the device list via Nova. 58 | 59 | This is the primary function for fetching the device list within Home Assistant, 60 | as it is non-blocking. 61 | 62 | Priority of HTTP session (HA best practice): 63 | 1) Explicit `session` argument (tests/special cases), 64 | 2) Registered provider from nova_request (uses HA's async_get_clientsession), 65 | 3) Short-lived fallback session managed by nova_request (DEBUG only). 66 | 67 | Args: 68 | username: The Google account username. If None, it will be retrieved 69 | from the cache. 70 | session: (Deprecated) The aiohttp ClientSession. This is no longer 71 | forwarded as nova_request handles session management. 72 | cache: Optional TokenCache instance for multi-account isolation. 73 | 74 | Returns: 75 | Hex-encoded Nova response payload. 76 | 77 | Raises: 78 | RuntimeError / aiohttp.ClientError on transport failures. 79 | """ 80 | hex_payload = create_device_list_request() 81 | # Delegate HTTP to Nova client (handles session provider & timeouts). 82 | return await async_nova_request( 83 | NOVA_LIST_DEVICES_API_SCOPE, 84 | hex_payload, 85 | username=username, 86 | cache=cache, 87 | # session intentionally not forwarded anymore; nova_request manages reuse/fallback 88 | ) 89 | 90 | 91 | def request_device_list() -> str: 92 | """Synchronous convenience wrapper for CLI/legacy callers. 93 | 94 | NOTE: 95 | - This wrapper spins a private event loop via `asyncio.run(...)`. 96 | - Do NOT call from inside an active event loop (will raise RuntimeError). 97 | - In Home Assistant, prefer `await async_request_device_list(...)` and await it. 98 | 99 | Returns: 100 | The hex-encoded response from the Nova API. 101 | 102 | Raises: 103 | RuntimeError: If called from within a running asyncio event loop. 104 | """ 105 | try: 106 | return asyncio.run(async_request_device_list()) 107 | except RuntimeError as err: 108 | # This indicates incorrect usage (called from within a running loop). 109 | _LOGGER.error( 110 | "request_device_list() must not be called inside an active event loop. " 111 | "Use async_request_device_list(...) instead. Error: %s", 112 | err, 113 | ) 114 | raise 115 | 116 | 117 | # ------------------------------ CLI helper --------------------------------- 118 | async def _async_cli_main() -> None: 119 | """Asynchronous main function for the CLI experience (single event loop). 120 | 121 | This function provides an interactive command-line interface for fetching 122 | device locations or registering new microcontroller-based trackers. 123 | It is intended for development and testing purposes. 124 | """ 125 | print("Loading...") 126 | result_hex = await async_request_device_list() 127 | 128 | device_list = parse_device_list_protobuf(result_hex) 129 | 130 | # Maintain side-effect helpers for Spot custom trackers. 131 | # NOTE: These imports are CLI-only to avoid heavy HA startup imports. 132 | from custom_components.googlefindmy.SpotApi.UploadPrecomputedPublicKeyIds.upload_precomputed_public_key_ids import ( # noqa: E501 133 | refresh_custom_trackers, 134 | ) 135 | 136 | refresh_custom_trackers(device_list) 137 | canonic_ids = get_canonic_ids(device_list) 138 | 139 | print("") 140 | print("-" * 50) 141 | print("Welcome to GoogleFindMyTools!") 142 | print("-" * 50) 143 | print("") 144 | print("The following trackers are available:") 145 | 146 | for idx, (device_name, canonic_id) in enumerate(canonic_ids, start=1): 147 | print(f"{idx}. {device_name}: {canonic_id}") 148 | 149 | selected_value = input( 150 | "\nIf you want to see locations of a tracker, type the number of the tracker and press 'Enter'.\n" 151 | "If you want to register a new ESP32- or Zephyr-based tracker, type 'r' and press 'Enter': " 152 | ) 153 | 154 | if selected_value == "r": 155 | print("Loading...") 156 | 157 | def _register_esp32_cli() -> None: 158 | """Synchronous helper to register a new ESP32 device.""" 159 | # Lazy import to avoid touching spot token logic at HA startup 160 | from custom_components.googlefindmy.SpotApi.CreateBleDevice.create_ble_device import ( 161 | register_esp32, 162 | ) 163 | register_esp32() 164 | 165 | # Run potential blocking/IO work in a worker thread to avoid blocking the loop. 166 | await asyncio.to_thread(_register_esp32_cli) 167 | else: 168 | selected_idx = int(selected_value) - 1 169 | selected_device_name = canonic_ids[selected_idx][0] 170 | selected_canonic_id = canonic_ids[selected_idx][1] 171 | 172 | print("Fetching location...") 173 | 174 | # Lazy import: only needed for the CLI branch 175 | from custom_components.googlefindmy.NovaApi.ExecuteAction.LocateTracker.location_request import ( # noqa: E501 176 | get_location_data_for_device, 177 | ) 178 | 179 | await get_location_data_for_device(selected_canonic_id, selected_device_name) 180 | 181 | 182 | if __name__ == "__main__": 183 | # This block allows the script to be run directly from the command line 184 | # for testing or manual device registration. 185 | try: 186 | asyncio.run(_async_cli_main()) 187 | except KeyboardInterrupt: 188 | print("\nExiting.") 189 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/proto/mcs_pb2.py: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: mcs.proto 3 | """Generated protocol buffer code.""" 4 | 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import descriptor_pool as _descriptor_pool 7 | from google.protobuf import symbol_database as _symbol_database 8 | from google.protobuf.internal import builder as _builder 9 | 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | # Create a separate descriptor pool to avoid conflicts 15 | _firebase_pool = _descriptor_pool.DescriptorPool() 16 | 17 | DESCRIPTOR = _firebase_pool.AddSerializedFile( 18 | b'\n\tmcs.proto\x12\tmcs_proto"S\n\rHeartbeatPing\x12\x11\n\tstream_id\x18\x01 \x01(\x05\x12\x1f\n\x17last_stream_id_received\x18\x02 \x01(\x05\x12\x0e\n\x06status\x18\x03 \x01(\x03"R\n\x0cHeartbeatAck\x12\x11\n\tstream_id\x18\x01 \x01(\x05\x12\x1f\n\x17last_stream_id_received\x18\x02 \x01(\x05\x12\x0e\n\x06status\x18\x03 \x01(\x03"a\n\tErrorInfo\x12\x0c\n\x04\x63ode\x18\x01 \x02(\x05\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\'\n\textension\x18\x04 \x01(\x0b\x32\x14.mcs_proto.Extension"&\n\x07Setting\x12\x0c\n\x04name\x18\x01 \x02(\t\x12\r\n\x05value\x18\x02 \x02(\t"A\n\rHeartbeatStat\x12\n\n\x02ip\x18\x01 \x02(\t\x12\x0f\n\x07timeout\x18\x02 \x02(\x08\x12\x13\n\x0binterval_ms\x18\x03 \x02(\x05"G\n\x0fHeartbeatConfig\x12\x13\n\x0bupload_stat\x18\x01 \x01(\x08\x12\n\n\x02ip\x18\x02 \x01(\t\x12\x13\n\x0binterval_ms\x18\x03 \x01(\x05"\xdb\x02\n\x0b\x43lientEvent\x12)\n\x04type\x18\x01 \x01(\x0e\x32\x1b.mcs_proto.ClientEvent.Type\x12\x1f\n\x17number_discarded_events\x18\x64 \x01(\r\x12\x15\n\x0cnetwork_type\x18\xc8\x01 \x01(\x05\x12#\n\x1atime_connection_started_ms\x18\xca\x01 \x01(\x04\x12!\n\x18time_connection_ended_ms\x18\xcb\x01 \x01(\x04\x12\x13\n\nerror_code\x18\xcc\x01 \x01(\x05\x12\'\n\x1etime_connection_established_ms\x18\xac\x02 \x01(\x04"[\n\x04Type\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x14\n\x10\x44ISCARDED_EVENTS\x10\x01\x12\x15\n\x11\x46\x41ILED_CONNECTION\x10\x02\x12\x19\n\x15SUCCESSFUL_CONNECTION\x10\x03J\x06\x08\xc9\x01\x10\xca\x01"\xff\x03\n\x0cLoginRequest\x12\n\n\x02id\x18\x01 \x02(\t\x12\x0e\n\x06\x64omain\x18\x02 \x02(\t\x12\x0c\n\x04user\x18\x03 \x02(\t\x12\x10\n\x08resource\x18\x04 \x02(\t\x12\x12\n\nauth_token\x18\x05 \x02(\t\x12\x11\n\tdevice_id\x18\x06 \x01(\t\x12\x13\n\x0blast_rmq_id\x18\x07 \x01(\x03\x12#\n\x07setting\x18\x08 \x03(\x0b\x32\x12.mcs_proto.Setting\x12\x1e\n\x16received_persistent_id\x18\n \x03(\t\x12\x1a\n\x12\x61\x64\x61ptive_heartbeat\x18\x0c \x01(\x08\x12\x30\n\x0eheartbeat_stat\x18\r \x01(\x0b\x32\x18.mcs_proto.HeartbeatStat\x12\x10\n\x08use_rmq2\x18\x0e \x01(\x08\x12\x12\n\naccount_id\x18\x0f \x01(\x03\x12\x39\n\x0c\x61uth_service\x18\x10 \x01(\x0e\x32#.mcs_proto.LoginRequest.AuthService\x12\x14\n\x0cnetwork_type\x18\x11 \x01(\x05\x12\x0e\n\x06status\x18\x12 \x01(\x03\x12,\n\x0c\x63lient_event\x18\x16 \x03(\x0b\x32\x16.mcs_proto.ClientEvent"\x1d\n\x0b\x41uthService\x12\x0e\n\nANDROID_ID\x10\x02J\x04\x08\x13\x10\x14J\x04\x08\x14\x10\x15J\x04\x08\x15\x10\x16"\xf6\x01\n\rLoginResponse\x12\n\n\x02id\x18\x01 \x02(\t\x12\x0b\n\x03jid\x18\x02 \x01(\t\x12#\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x14.mcs_proto.ErrorInfo\x12#\n\x07setting\x18\x04 \x03(\x0b\x32\x12.mcs_proto.Setting\x12\x11\n\tstream_id\x18\x05 \x01(\x05\x12\x1f\n\x17last_stream_id_received\x18\x06 \x01(\x05\x12\x34\n\x10heartbeat_config\x18\x07 \x01(\x0b\x32\x1a.mcs_proto.HeartbeatConfig\x12\x18\n\x10server_timestamp\x18\x08 \x01(\x03"/\n\x11StreamErrorStanza\x12\x0c\n\x04type\x18\x01 \x02(\t\x12\x0c\n\x04text\x18\x02 \x01(\t"\x07\n\x05\x43lose"%\n\tExtension\x12\n\n\x02id\x18\x01 \x02(\x05\x12\x0c\n\x04\x64\x61ta\x18\x02 \x02(\x0c"\xdd\x02\n\x08IqStanza\x12\x0e\n\x06rmq_id\x18\x01 \x01(\x03\x12(\n\x04type\x18\x02 \x02(\x0e\x32\x1a.mcs_proto.IqStanza.IqType\x12\n\n\x02id\x18\x03 \x02(\t\x12\x0c\n\x04\x66rom\x18\x04 \x01(\t\x12\n\n\x02to\x18\x05 \x01(\t\x12#\n\x05\x65rror\x18\x06 \x01(\x0b\x32\x14.mcs_proto.ErrorInfo\x12\'\n\textension\x18\x07 \x01(\x0b\x32\x14.mcs_proto.Extension\x12\x15\n\rpersistent_id\x18\x08 \x01(\t\x12\x11\n\tstream_id\x18\t \x01(\x05\x12\x1f\n\x17last_stream_id_received\x18\n \x01(\x05\x12\x12\n\naccount_id\x18\x0b \x01(\x03\x12\x0e\n\x06status\x18\x0c \x01(\x03"4\n\x06IqType\x12\x07\n\x03GET\x10\x00\x12\x07\n\x03SET\x10\x01\x12\n\n\x06RESULT\x10\x02\x12\x0c\n\x08IQ_ERROR\x10\x03"%\n\x07\x41ppData\x12\x0b\n\x03key\x18\x01 \x02(\t\x12\r\n\x05value\x18\x02 \x02(\t"\xf4\x02\n\x11\x44\x61taMessageStanza\x12\n\n\x02id\x18\x02 \x01(\t\x12\x0c\n\x04\x66rom\x18\x03 \x02(\t\x12\n\n\x02to\x18\x04 \x01(\t\x12\x10\n\x08\x63\x61tegory\x18\x05 \x02(\t\x12\r\n\x05token\x18\x06 \x01(\t\x12$\n\x08\x61pp_data\x18\x07 \x03(\x0b\x32\x12.mcs_proto.AppData\x12\x1b\n\x13\x66rom_trusted_server\x18\x08 \x01(\x08\x12\x15\n\rpersistent_id\x18\t \x01(\t\x12\x11\n\tstream_id\x18\n \x01(\x05\x12\x1f\n\x17last_stream_id_received\x18\x0b \x01(\x05\x12\x0e\n\x06reg_id\x18\r \x01(\t\x12\x16\n\x0e\x64\x65vice_user_id\x18\x10 \x01(\x03\x12\x0b\n\x03ttl\x18\x11 \x01(\x05\x12\x0c\n\x04sent\x18\x12 \x01(\x03\x12\x0e\n\x06queued\x18\x13 \x01(\x05\x12\x0e\n\x06status\x18\x14 \x01(\x03\x12\x10\n\x08raw_data\x18\x15 \x01(\x0c\x12\x15\n\rimmediate_ack\x18\x18 \x01(\x08"\x0b\n\tStreamAck"\x1a\n\x0cSelectiveAck\x12\n\n\x02id\x18\x01 \x03(\tB\x02H\x03' 19 | ) 20 | 21 | _globals = globals() 22 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 23 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "mcs_pb2", _globals) 24 | if _descriptor._USE_C_DESCRIPTORS == False: 25 | _globals["DESCRIPTOR"]._options = None 26 | _globals["DESCRIPTOR"]._serialized_options = b"H\003" 27 | _globals["_HEARTBEATPING"]._serialized_start = 24 28 | _globals["_HEARTBEATPING"]._serialized_end = 107 29 | _globals["_HEARTBEATACK"]._serialized_start = 109 30 | _globals["_HEARTBEATACK"]._serialized_end = 191 31 | _globals["_ERRORINFO"]._serialized_start = 193 32 | _globals["_ERRORINFO"]._serialized_end = 290 33 | _globals["_SETTING"]._serialized_start = 292 34 | _globals["_SETTING"]._serialized_end = 330 35 | _globals["_HEARTBEATSTAT"]._serialized_start = 332 36 | _globals["_HEARTBEATSTAT"]._serialized_end = 397 37 | _globals["_HEARTBEATCONFIG"]._serialized_start = 399 38 | _globals["_HEARTBEATCONFIG"]._serialized_end = 470 39 | _globals["_CLIENTEVENT"]._serialized_start = 473 40 | _globals["_CLIENTEVENT"]._serialized_end = 820 41 | _globals["_CLIENTEVENT_TYPE"]._serialized_start = 721 42 | _globals["_CLIENTEVENT_TYPE"]._serialized_end = 812 43 | _globals["_LOGINREQUEST"]._serialized_start = 823 44 | _globals["_LOGINREQUEST"]._serialized_end = 1334 45 | _globals["_LOGINREQUEST_AUTHSERVICE"]._serialized_start = 1287 46 | _globals["_LOGINREQUEST_AUTHSERVICE"]._serialized_end = 1316 47 | _globals["_LOGINRESPONSE"]._serialized_start = 1337 48 | _globals["_LOGINRESPONSE"]._serialized_end = 1583 49 | _globals["_STREAMERRORSTANZA"]._serialized_start = 1585 50 | _globals["_STREAMERRORSTANZA"]._serialized_end = 1632 51 | _globals["_CLOSE"]._serialized_start = 1634 52 | _globals["_CLOSE"]._serialized_end = 1641 53 | _globals["_EXTENSION"]._serialized_start = 1643 54 | _globals["_EXTENSION"]._serialized_end = 1680 55 | _globals["_IQSTANZA"]._serialized_start = 1683 56 | _globals["_IQSTANZA"]._serialized_end = 2032 57 | _globals["_IQSTANZA_IQTYPE"]._serialized_start = 1980 58 | _globals["_IQSTANZA_IQTYPE"]._serialized_end = 2032 59 | _globals["_APPDATA"]._serialized_start = 2034 60 | _globals["_APPDATA"]._serialized_end = 2071 61 | _globals["_DATAMESSAGESTANZA"]._serialized_start = 2074 62 | _globals["_DATAMESSAGESTANZA"]._serialized_end = 2446 63 | _globals["_STREAMACK"]._serialized_start = 2448 64 | _globals["_STREAMACK"]._serialized_end = 2459 65 | _globals["_SELECTIVEACK"]._serialized_start = 2461 66 | _globals["_SELECTIVEACK"]._serialized_end = 2487 67 | # @@protoc_insertion_point(module_scope) 68 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/SpotApi/GetEidInfoForE2eeDevices/get_eid_info_request.py: -------------------------------------------------------------------------------- 1 | # custom_components/googlefindmy/SpotApi/GetEidInfoForE2eeDevices/get_eid_info_request.py 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | """ 6 | GetEidInfoForE2eeDevices request helpers (async-first). 7 | 8 | This module exposes: 9 | - `async_get_eid_info()`: primary non-blocking API used by the integration. 10 | - `get_eid_info()`: guarded sync wrapper for CLI/testing only. It fails fast 11 | if invoked from the Home Assistant event loop. 12 | 13 | Behavior 14 | -------- 15 | - Builds the protobuf request (ownerKeyVersion = -1 to request the latest key). 16 | - Calls the SPOT endpoint via the Spot API request helper. 17 | - Raises `SpotApiEmptyResponseError` on trailers-only/empty bodies for clear 18 | and actionable error handling upstream. 19 | - Parses the protobuf response defensively and logs concise diagnostics. 20 | 21 | Notes 22 | ----- 23 | - The async path prefers `async_spot_request()` if available. If the legacy 24 | synchronous `spot_request()` is the only option, it is executed safely in 25 | a worker thread to avoid blocking the event loop. 26 | """ 27 | 28 | from __future__ import annotations 29 | 30 | import asyncio 31 | import logging 32 | from typing import Optional 33 | 34 | from google.protobuf.message import DecodeError # parse-time error type for protobufs 35 | 36 | from custom_components.googlefindmy.ProtoDecoders import Common_pb2 37 | from custom_components.googlefindmy.ProtoDecoders import DeviceUpdate_pb2 38 | 39 | _LOGGER = logging.getLogger(__name__) 40 | 41 | 42 | class SpotApiEmptyResponseError(RuntimeError): 43 | """Raised when a SPOT API call returns an empty body where one was expected.""" 44 | 45 | 46 | def _build_request_bytes() -> bytes: 47 | """Build and serialize the GetEidInfoForE2eeDevices protobuf request. 48 | 49 | Returns: 50 | Serialized request bytes suitable for the Spot API. 51 | """ 52 | req = Common_pb2.GetEidInfoForE2eeDevicesRequest() 53 | # API convention: -1 means "latest available owner key" 54 | req.ownerKeyVersion = -1 55 | req.hasOwnerKeyVersion = True 56 | return req.SerializeToString() 57 | 58 | 59 | async def _spot_call_async(scope: str, payload: bytes) -> bytes: 60 | """Call the Spot API asynchronously. 61 | 62 | Prefer an async helper if available; otherwise run the sync helper in an executor. 63 | 64 | Args: 65 | scope: Spot API method name (e.g., "GetEidInfoForE2eeDevices"). 66 | payload: Serialized protobuf request. 67 | 68 | Returns: 69 | Raw response bytes. 70 | 71 | Raises: 72 | RuntimeError: on underlying request errors. 73 | """ 74 | # Try native async helper first 75 | try: 76 | from custom_components.googlefindmy.SpotApi.spot_request import ( # type: ignore 77 | async_spot_request, 78 | ) 79 | 80 | if callable(async_spot_request): 81 | return await async_spot_request(scope, payload) 82 | except Exception: 83 | # Fallback to sync path below 84 | pass 85 | 86 | # Fallback: run synchronous spot_request in a worker thread 87 | from custom_components.googlefindmy.SpotApi.spot_request import spot_request # type: ignore 88 | 89 | loop = asyncio.get_running_loop() 90 | return await loop.run_in_executor(None, spot_request, scope, payload) 91 | 92 | 93 | async def async_get_eid_info() -> DeviceUpdate_pb2.GetEidInfoForE2eeDevicesResponse: 94 | """Fetch and parse EID info for E2EE devices (async, preferred). 95 | 96 | Returns: 97 | Parsed `GetEidInfoForE2eeDevicesResponse` protobuf message. 98 | 99 | Raises: 100 | SpotApiEmptyResponseError: if the response body is empty (e.g., trailers-only). 101 | DecodeError: if the protobuf payload cannot be parsed. 102 | RuntimeError: for lower-level Spot API request failures. 103 | """ 104 | serialized_request = _build_request_bytes() 105 | response_bytes = await _spot_call_async("GetEidInfoForE2eeDevices", serialized_request) 106 | 107 | # Defensive checks + diagnostics for trailers-only / empty payloads 108 | if not response_bytes: 109 | # Actionable guidance: most often caused by expired/invalid auth; forces re-auth in higher layers 110 | _LOGGER.warning( 111 | "GetEidInfoForE2eeDevices: empty/none response (len=0, pre=). " 112 | "This often indicates an authentication issue (trailers-only with grpc-status!=0). " 113 | "If this persists after a token refresh, please re-authenticate your Google account." 114 | ) 115 | raise SpotApiEmptyResponseError( 116 | "Empty gRPC body (possibly trailers-only) for GetEidInfoForE2eeDevices" 117 | ) 118 | 119 | eid_info = DeviceUpdate_pb2.GetEidInfoForE2eeDevicesResponse() 120 | try: 121 | eid_info.ParseFromString(response_bytes) 122 | except DecodeError: 123 | # Provide minimal, high-signal context to help diagnose corrupted/incompatible payloads 124 | _LOGGER.warning( 125 | "GetEidInfoForE2eeDevices: protobuf DecodeError (len=%s, pre=%s). " 126 | "This may indicate a truncated/corrupted gRPC response or a server-side format change. " 127 | "If this persists, try re-authenticating.", 128 | len(response_bytes), 129 | response_bytes[:16].hex(), 130 | ) 131 | raise 132 | 133 | return eid_info 134 | 135 | 136 | def get_eid_info() -> DeviceUpdate_pb2.GetEidInfoForE2eeDevicesResponse: 137 | """Synchronous helper for CLI/testing only (NOT for use inside HA's event loop). 138 | 139 | Raises: 140 | RuntimeError: if called from a running event loop. 141 | SpotApiEmptyResponseError, DecodeError: see `async_get_eid_info`. 142 | """ 143 | # Fail-fast if used in an event loop 144 | try: 145 | loop = asyncio.get_running_loop() 146 | if loop.is_running(): 147 | raise RuntimeError( 148 | "Sync get_eid_info() called from within the event loop. " 149 | "Use `await async_get_eid_info()` instead." 150 | ) 151 | except RuntimeError: 152 | # No running loop -> OK for CLI usage 153 | pass 154 | 155 | # Keep the historical synchronous behavior (used by CLI/dev tools), 156 | # but the integration should migrate to the async API. 157 | from custom_components.googlefindmy.SpotApi.spot_request import spot_request # type: ignore 158 | 159 | serialized_request = _build_request_bytes() 160 | response_bytes = spot_request("GetEidInfoForE2eeDevices", serialized_request) 161 | 162 | if not response_bytes: 163 | _LOGGER.warning( 164 | "GetEidInfoForE2eeDevices: empty/none response (len=0, pre=). " 165 | "This often indicates an authentication issue (trailers-only with grpc-status!=0). " 166 | "If this persists after a token refresh, please re-authenticate your Google account." 167 | ) 168 | raise SpotApiEmptyResponseError( 169 | "Empty gRPC body (possibly trailers-only) for GetEidInfoForE2eeDevices" 170 | ) 171 | 172 | eid_info = DeviceUpdate_pb2.GetEidInfoForE2eeDevicesResponse() 173 | try: 174 | eid_info.ParseFromString(response_bytes) 175 | except DecodeError: 176 | _LOGGER.warning( 177 | "GetEidInfoForE2eeDevices: protobuf DecodeError (len=%s, pre=%s). " 178 | "This may indicate a truncated/corrupted gRPC response or a server-side format change. " 179 | "If this persists, try re-authenticating.", 180 | len(response_bytes), 181 | response_bytes[:16].hex(), 182 | ) 183 | raise 184 | 185 | return eid_info 186 | 187 | 188 | if __name__ == "__main__": 189 | # CLI/dev convenience: print whether owner-key metadata is present 190 | info = get_eid_info() 191 | print(getattr(info, "encryptedOwnerKeyAndMetadata", None)) 192 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/ProtoDecoders/DeviceUpdate.proto: -------------------------------------------------------------------------------- 1 | // 2 | // GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | // Copyright © 2024 Leon Böttger. All rights reserved. 4 | // 5 | 6 | syntax = "proto3"; 7 | import "ProtoDecoders/Common.proto"; 8 | 9 | message GetEidInfoForE2eeDevicesResponse { 10 | EncryptedOwnerKeyAndMetadata encryptedOwnerKeyAndMetadata = 4; 11 | } 12 | 13 | message EncryptedOwnerKeyAndMetadata { 14 | bytes encryptedOwnerKey = 1; 15 | int32 ownerKeyVersion = 2; 16 | string securityDomain = 3; 17 | } 18 | 19 | message DevicesList { 20 | repeated DeviceMetadata deviceMetadata = 2; 21 | } 22 | 23 | message DevicesListRequest { 24 | DevicesListRequestPayload deviceListRequestPayload = 1; 25 | } 26 | 27 | message DevicesListRequestPayload { 28 | DeviceType type = 1; 29 | string id = 3; 30 | } 31 | 32 | enum DeviceType { 33 | UNKNOWN_DEVICE_TYPE = 0; 34 | ANDROID_DEVICE = 1; 35 | SPOT_DEVICE = 2; 36 | TEST_DEVICE_TYPE = 3; 37 | AUTO_DEVICE = 4; 38 | FASTPAIR_DEVICE = 5; 39 | SUPERVISED_ANDROID_DEVICE = 7; 40 | } 41 | 42 | message ExecuteActionRequest { 43 | ExecuteActionScope scope = 1; 44 | ExecuteActionType action = 2; 45 | ExecuteActionRequestMetadata requestMetadata = 3; 46 | } 47 | 48 | message ExecuteActionRequestMetadata { 49 | DeviceType type = 1; 50 | string requestUuid = 2; 51 | string fmdClientUuid = 3; 52 | GcmCloudMessagingIdProtobuf gcmRegistrationId = 4; 53 | bool unknown = 6; 54 | } 55 | 56 | message GcmCloudMessagingIdProtobuf { 57 | string id = 1; 58 | } 59 | 60 | message ExecuteActionType { 61 | ExecuteActionLocateTrackerType locateTracker = 30; 62 | ExecuteActionSoundType startSound = 31; 63 | ExecuteActionSoundType stopSound = 32; 64 | } 65 | 66 | message ExecuteActionLocateTrackerType { 67 | Time lastHighTrafficEnablingTime = 2; 68 | SpotContributorType contributorType = 3; 69 | } 70 | 71 | enum SpotContributorType { 72 | FMDN_DISABLED_DEFAULT = 0; 73 | FMDN_CONTRIBUTOR_HIGH_TRAFFIC = 3; 74 | FMDN_CONTRIBUTOR_ALL_LOCATIONS = 4; 75 | FMDN_HIGH_TRAFFIC = 1; 76 | FMDN_ALL_LOCATIONS = 2; 77 | } 78 | 79 | message ExecuteActionSoundType { 80 | DeviceComponent component = 1; 81 | } 82 | 83 | enum DeviceComponent { 84 | DEVICE_COMPONENT_UNSPECIFIED = 0; 85 | DEVICE_COMPONENT_RIGHT = 1; 86 | DEVICE_COMPONENT_LEFT = 2; 87 | DEVICE_COMPONENT_CASE = 3; 88 | } 89 | 90 | message ExecuteActionScope { 91 | DeviceType type = 2; 92 | ExecuteActionDeviceIdentifier device = 3; 93 | } 94 | 95 | message ExecuteActionDeviceIdentifier { 96 | CanonicId canonicId = 1; 97 | } 98 | 99 | message DeviceUpdate { 100 | ExecuteActionRequestMetadata fcmMetadata = 1; 101 | DeviceMetadata deviceMetadata = 3; 102 | RequestMetadata requestMetadata = 2; 103 | } 104 | 105 | message DeviceMetadata { 106 | IdentitfierInformation identifierInformation = 1; 107 | DeviceInformation information = 4; 108 | string userDefinedDeviceName = 5; 109 | ImageInformation imageInformation = 6; 110 | } 111 | 112 | message ImageInformation { 113 | string imageUrl = 1; 114 | } 115 | 116 | message IdentitfierInformation { 117 | PhoneInformation phoneInformation = 1; 118 | IdentifierInformationType type = 2; 119 | CanonicIds canonicIds = 3; 120 | } 121 | 122 | enum IdentifierInformationType { 123 | IDENTIFIER_UNKNOWN = 0; 124 | IDENTIFIER_ANDROID = 1; 125 | IDENTIFIER_SPOT = 2; 126 | } 127 | 128 | message PhoneInformation { 129 | CanonicIds canonicIds = 2; 130 | } 131 | 132 | message CanonicIds { 133 | repeated CanonicId canonicId = 1; 134 | } 135 | 136 | message CanonicId { 137 | string id = 1; 138 | } 139 | 140 | message DeviceInformation { 141 | DeviceRegistration deviceRegistration = 1; 142 | LocationInformation locationInformation = 2; 143 | repeated AccessInformation accessInformation = 3; 144 | } 145 | 146 | message DeviceTypeInformation { 147 | SpotDeviceType deviceType = 2; 148 | } 149 | 150 | message DeviceRegistration { 151 | DeviceTypeInformation deviceTypeInformation = 2; 152 | EncryptedUserSecrets encryptedUserSecrets = 19; 153 | string manufacturer = 20; 154 | string fastPairModelId = 21; 155 | int32 pairDate = 23; 156 | string model = 34; 157 | } 158 | 159 | message EncryptedUserSecrets { 160 | bytes encryptedIdentityKey = 1; 161 | int32 ownerKeyVersion = 3; 162 | bytes encryptedAccountKey = 4; 163 | Time creationDate = 8; 164 | bytes encryptedSha256AccountKeyPublicAddress = 11; 165 | } 166 | 167 | message LocationInformation { 168 | LocationsAndTimestampsWrapper reports = 3; 169 | } 170 | 171 | message LocationsAndTimestampsWrapper { 172 | RecentLocationAndNetworkLocations recentLocationAndNetworkLocations = 4; 173 | } 174 | 175 | message RecentLocationAndNetworkLocations { 176 | LocationReport recentLocation = 1; 177 | Time recentLocationTimestamp = 2; 178 | repeated LocationReport networkLocations = 5; 179 | repeated Time networkLocationTimestamps = 6; 180 | uint32 minLocationsNeededForAggregation = 9; 181 | } 182 | 183 | enum SpotDeviceType { 184 | DEVICE_TYPE_UNKNOWN = 0; 185 | DEVICE_TYPE_BEACON = 1; 186 | DEVICE_TYPE_HEADPHONES = 2; 187 | DEVICE_TYPE_KEYS = 3; 188 | DEVICE_TYPE_WATCH = 4; 189 | DEVICE_TYPE_WALLET = 5; 190 | DEVICE_TYPE_BAG = 7; 191 | DEVICE_TYPE_LAPTOP = 8; 192 | DEVICE_TYPE_CAR = 9; 193 | DEVICE_TYPE_REMOTE_CONTROL = 10; 194 | DEVICE_TYPE_BADGE = 11; 195 | DEVICE_TYPE_BIKE = 12; 196 | DEVICE_TYPE_CAMERA = 13; 197 | DEVICE_TYPE_CAT = 14; 198 | DEVICE_TYPE_CHARGER = 15; 199 | DEVICE_TYPE_CLOTHING = 16; 200 | DEVICE_TYPE_DOG = 17; 201 | DEVICE_TYPE_NOTEBOOK = 18; 202 | DEVICE_TYPE_PASSPORT = 19; 203 | DEVICE_TYPE_PHONE = 20; 204 | DEVICE_TYPE_SPEAKER = 21; 205 | DEVICE_TYPE_TABLET = 22; 206 | DEVICE_TYPE_TOY = 23; 207 | DEVICE_TYPE_UMBRELLA = 24; 208 | DEVICE_TYPE_STYLUS = 25; 209 | DEVICE_TYPE_EARBUDS = 26; 210 | } 211 | 212 | message AccessInformation { 213 | string email = 1; 214 | bool hasAccess = 2; 215 | bool isOwner = 3; 216 | bool thisAccount = 4; 217 | } 218 | 219 | message RequestMetadata { 220 | Time responseTime = 1; 221 | } 222 | 223 | 224 | message EncryptionUnlockRequestExtras { 225 | int32 operation = 1; 226 | SecurityDomain securityDomain = 2; 227 | string sessionId = 6; 228 | } 229 | 230 | message SecurityDomain { 231 | string name = 1; 232 | int32 unknown = 2; 233 | } 234 | 235 | message Location { 236 | sfixed32 latitude = 1; 237 | sfixed32 longitude = 2; 238 | int32 altitude = 3; 239 | } 240 | 241 | message RegisterBleDeviceRequest { 242 | string fastPairModelId = 7; 243 | DeviceDescription description = 10; 244 | DeviceCapabilities capabilities = 11; 245 | 246 | E2EEPublicKeyRegistration e2eePublicKeyRegistration = 16; 247 | 248 | string manufacturerName = 17; 249 | bytes ringKey = 21; 250 | bytes recoveryKey = 22; 251 | bytes unwantedTrackingKey = 24; 252 | string modelName = 25; 253 | } 254 | 255 | message E2EEPublicKeyRegistration { 256 | int32 rotationExponent = 1; 257 | EncryptedUserSecrets encryptedUserSecrets = 3; 258 | PublicKeyIdList publicKeyIdList = 4; 259 | int32 pairingDate = 5; 260 | } 261 | 262 | message PublicKeyIdList { 263 | repeated PublicKeyIdInfo publicKeyIdInfo = 1; 264 | 265 | message PublicKeyIdInfo { 266 | Time timestamp = 1; 267 | TruncatedEID publicKeyId = 2; 268 | int32 trackableComponent = 3; 269 | } 270 | } 271 | 272 | message TruncatedEID { 273 | bytes truncatedEid = 1; 274 | } 275 | 276 | message UploadPrecomputedPublicKeyIdsRequest { 277 | repeated DevicePublicKeyIds deviceEids = 1; 278 | 279 | message DevicePublicKeyIds { 280 | CanonicId canonicId = 1; 281 | PublicKeyIdList clientList = 2; 282 | int32 pairDate = 3; 283 | } 284 | } 285 | 286 | message DeviceCapabilities { 287 | bool isAdvertising = 1; 288 | int32 capableComponents = 5; 289 | int32 trackableComponents = 6; 290 | } 291 | 292 | message DeviceDescription { 293 | string userDefinedName = 1; 294 | SpotDeviceType deviceType = 2; 295 | repeated DeviceComponentInformation deviceComponentsInformation = 9; 296 | } 297 | 298 | message DeviceComponentInformation { 299 | string imageUrl = 1; 300 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google FindMy Device (Find Hub) - Home Assistant Integration 2 | 3 | >[!CAUTION] 4 | > ## **V1.7 Semi-Breaking Change** 5 | > 6 | > After installing this update, you must delete your existing configuration and re-add the integration. This is due to major architectural changes. Location history should not be affected. 7 | 8 | --- 9 | 10 | A comprehensive Home Assistant custom integration for Google's FindMy Device network, enabling real-time(ish) tracking and control of FindMy devices directly within Home Assistant! 11 | 12 | >[!TIP] 13 | >**Check out my companion Lovelace card, designed to work perfectly with this integration!** 14 | > 15 | >**[Google FindMy Card!](https://github.com/BSkando/GoogleFindMy-Card)** 16 | 17 | ## Come join our Discord for real time help and chat! 18 | 19 | [Google FindMy Discord Server](https://discord.gg/U3MkcbGzhc) 20 | 21 | --- 22 | [![GitHub Repo stars](https://img.shields.io/github/stars/BSkando/GoogleFindMy-HA?style=for-the-badge&logo=github)](https://github.com/BSkando/GoogleFindMy-HA) [![Home Assistant Community Forum](https://img.shields.io/badge/Home%20Assistant-Community%20Forum-blue?style=for-the-badge&logo=home-assistant)](https://community.home-assistant.io/t/google-findmy-find-hub-integration/931136) [![Buy me a coffee](https://img.shields.io/badge/Coffee-Addiction!-yellow?style=for-the-badge&logo=buy-me-a-coffee)](https://www.buymeacoffee.com/bskando) 23 | 24 | --- 25 | ## Features 26 | 27 | - 🗺️ **Real-time Device Tracking**: Track Google FindMy devices with location data, sourced from the FindMy network 28 | - ⏱️ **Configurable Polling**: Flexible polling intervals with rate limit protection 29 | - 🔔 **Sound Button Entity**: Devices include button entity that plays a sound on supported devices 30 | - ✅ **Attribute grading system**: Best location data is selected automatically based on recency, accuracy, and source of data 31 | - 📍 **Historical Map-View**: Each tracker has a filterable Map-View that shows tracker movement with location data 32 | - 📋 **Statistic Entity**: Detailed statistics for monitoring integration performance 33 | - #️⃣ **Multi-Account Support**: Add multiple Find Hub Google accounts that show up separately 34 | - ❣️ **More to come!** 35 | 36 | >[!NOTE] 37 | >**This is a true integration! No docker containers, external systems, or scripts required (other than for initial authentication)!** 38 | > 39 | ## Installation 40 | 41 | ### HACS (Recommended) 42 | 1. Click the button below to add this custom repository to HACS\ 43 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?category=integration&repository=GoogleFindMy-HA&owner=BSkando) 44 | 2. Install "Google Find My Device" from HACS 45 | 3. Restart Home Assistant 46 | 4. Add the integration through the UI 47 | 48 | ### Manual Installation 49 | 1. Download this repository 50 | 2. Copy the `googlefindmy` folder to `custom_components/` 51 | 3. Restart Home Assistant 52 | 4. Add the integration through the UI 53 | 54 | ## First-Time Setup 55 | 56 | >[!IMPORTANT] 57 | >**Authentication is a 2-part process. One part requires use of a python script to obtain a secrets.json file, which will contain all necessary keys for authentication! This is currently the *ONLY* way to authenticate to the FindMy network.** 58 | 59 | ### Authentication Part 1 (External Steps) 60 | 1. Navigate to [GoogleFindMyTools](https://github.com/leonboe1/GoogleFindMyTools?tab=readme-ov-file#how-to-use) repository and follow the directions on "How to use" the main.py script. 61 | 2. **CRITICAL STEP!** Complete the **ENTIRE** authentication process to generate `Auth/secrets.json` 62 | > [!WARNING] 63 | >While going through the process in main.py to authenticate, you **MUST** go through **2 login processes!** After the first login is successful, your available devices will be listed. You must complete the next step to display location data for one of your devices. You will then login again. After you complete this step, you should see valid location data for your device, followed by several errors that are not important. ONLY at this point are you ready to move on to the next step! 64 | 3. Copy the entire contents of the secrets.json file. 65 | - Specifically, open the file in a text editor, select all, and copy. 66 | 67 | ### Authentication Part 2 (Home Assistant Steps) 68 | 4. Add the integration to your Home Assistant install. 69 | 5. In Home Assistant, paste the copied text from secrets.json when prompted. 70 | 6. After completing authentication and adding devices, RESTART Home Assistant! 71 | 72 | ### Problems with Authentication? 73 | >[!NOTE] 74 | >Recently, some have had issues with the script from the repository above. If you follow all the steps in Leon's repository and are unable to get through the main.py sequence due to errors, please try using my modification of the script [BACKUP:GoogleFindMyTools](https://github.com/BSkando/GoogleFindMyTools) 75 | 76 | ## Configuration Options 77 | 78 | Accessible via the ⚙️ cogwheel button on the main Google Find My Device Integration page. 79 | 80 | | **Option** | **Default** | **Units** | **Description** | 81 | | :---: | :---: | :---: | --- | 82 | | tracked_devices | - | - | Select which devices from your account are tracked with the integration. | 83 | | location_poll_interval | 300 | seconds | How often the integration runs a poll cycle for all devices | 84 | | device_poll_delay | 5 | seconds | How much time to wait between polling devices during a poll cycle | 85 | | min_accuract_threshold | 100 | meters | Distance beyond which location data will be rejected from writing to logbook/recorder | 86 | | movement_threshold | 50 | meters | Distance a device must travel to show an update in device location | 87 | | google_home_filter_enabled | true | toggle | Enables/disables Google Home device location update filtering | 88 | | google_home_filter_keywords | various | text input | Keywords, separated by commas, that are used in filtering out location data from Google Home devices | 89 | | enable_stats_entities | true | toggle | Enables/disables "Google Find My Integration" statistics entity, which displays various useful statistics, including when polling is active | 90 | | map_vew_token_expiration | false | toggle | Enables/disables expiration of generated API token for accessing recorder history, used in Map View location data queries | 91 | 92 | ## Services (Actions) 93 | 94 | The integration provides a couple of Home Assistant Actions for use with automations. Note that Device ID is different than Entity ID. Device ID is a long, alpha-numeric value that can be obtained from the Device info pages. 95 | 96 | | Action | Attribute | Description | 97 | | :---: | :---: | --- | 98 | | googlefindmy.locate_device | Device ID | Request fresh location data for a specific device. | 99 | | googlefindmy.play_sound | Device ID | Play a sound on a specific device for location assistance. Devices must be capable of playing a sound. Most devices should be compatible. | 100 | | googlefindmy.refresh_device_urls | - | Refreshes all device Map View URLs. Useful if you are having problems with accessing Map View pages. | 101 | 102 | ## Troubleshooting 103 | 104 | ### No Location Data 105 | - Check if devices have moved recently (Find My devices may not update GPS when stationary) 106 | - Check battery levels (low battery may disable GPS reporting) 107 | 108 | ### FCM Connection Problems 109 | - Extended timeout allows up to 60 seconds for device response 110 | - Check firewall settings for Firebase Cloud Messaging 111 | - Review FCM debug logs for connection details 112 | 113 | ### Rate Limiting 114 | The integration respects Google's rate limits by: 115 | - Sequential device polling (one device at a time) 116 | - Configurable delays between requests 117 | - Minimum poll interval enforcement 118 | - Automatic retry with exponential backoff 119 | 120 | ## Privacy and Security 121 | 122 | - All location data uses Google's end-to-end encryption 123 | - Authentication tokens are securely cached 124 | - No location data is transmitted to third parties 125 | - Local processing of all GPS coordinates 126 | 127 | ## Contributing 128 | 129 | Contributions are welcome and encouraged! 130 | 131 | To contrubuted, please: 132 | 1. Fork the repository 133 | 2. Create a feature branch 134 | 3. Test thoroughly with your Find My devices 135 | 4. Submit a pull request with detailed description 136 | 137 | ## Credits 138 | 139 | - Böttger, L. (2024). GoogleFindMyTools [Computer software]. https://github.com/leonboe1/GoogleFindMyTools 140 | - Firebase Cloud Messaging integration. https://github.com/home-assistant/mobile-apps-fcm-push 141 | - @txitxo0 for his amazing work on the MQTT based tool that I used to help kickstart this project! 142 | 143 | ## Special thanks to some amazing contributors! 144 | 145 | - @DominicWindisch 146 | - @suka97 147 | - @jleinenbach 148 | 149 | ## Disclaimer 150 | 151 | This integration is not affiliated with Google. Use at your own risk and in compliance with Google's Terms of Service. The developers are not responsible for any misuse or issues arising from the use of this integration. 152 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/proto/mcs.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Chromium Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | // 5 | // MCS protocol for communication between Chrome client and Mobile Connection 6 | // Server . 7 | 8 | syntax = "proto2"; 9 | 10 | option optimize_for = LITE_RUNTIME; 11 | 12 | package mcs_proto; 13 | 14 | /* 15 | Common fields/comments: 16 | 17 | stream_id: no longer sent by server, each side keeps a counter 18 | last_stream_id_received: sent only if a packet was received since last time 19 | a last_stream was sent 20 | status: new bitmask including the 'idle' as bit 0. 21 | 22 | */ 23 | 24 | /** 25 | TAG: 0 26 | */ 27 | message HeartbeatPing { 28 | optional int32 stream_id = 1; 29 | optional int32 last_stream_id_received = 2; 30 | optional int64 status = 3; 31 | } 32 | 33 | /** 34 | TAG: 1 35 | */ 36 | message HeartbeatAck { 37 | optional int32 stream_id = 1; 38 | optional int32 last_stream_id_received = 2; 39 | optional int64 status = 3; 40 | } 41 | 42 | message ErrorInfo { 43 | required int32 code = 1; 44 | optional string message = 2; 45 | optional string type = 3; 46 | optional Extension extension = 4; 47 | } 48 | 49 | // MobileSettings class. 50 | // "u:f", "u:b", "u:s" - multi user devices reporting foreground, background 51 | // and stopped users. 52 | // hbping: heatbeat ping interval 53 | // rmq2v: include explicit stream IDs 54 | 55 | message Setting { 56 | required string name = 1; 57 | required string value = 2; 58 | } 59 | 60 | message HeartbeatStat { 61 | required string ip = 1; 62 | required bool timeout = 2; 63 | required int32 interval_ms = 3; 64 | } 65 | 66 | message HeartbeatConfig { 67 | optional bool upload_stat = 1; 68 | optional string ip = 2; 69 | optional int32 interval_ms = 3; 70 | } 71 | 72 | // ClientEvents are used to inform the server of failed and successful 73 | // connections. 74 | message ClientEvent { 75 | enum Type { 76 | UNKNOWN = 0; 77 | // Count of discarded events if the buffer filled up and was trimmed. 78 | DISCARDED_EVENTS = 1; 79 | // Failed connection event: the connection failed to be established or we 80 | // had a login error. 81 | FAILED_CONNECTION = 2; 82 | // Successful connection event: information about the last successful 83 | // connection, including the time at which it was established. 84 | SUCCESSFUL_CONNECTION = 3; 85 | } 86 | 87 | // Common fields [1-99] 88 | optional Type type = 1; 89 | 90 | // Fields for DISCARDED_EVENTS messages [100-199] 91 | optional uint32 number_discarded_events = 100; 92 | 93 | // Fields for FAILED_CONNECTION and SUCCESSFUL_CONNECTION messages [200-299] 94 | // Network type is a value in net::NetworkChangeNotifier::ConnectionType. 95 | optional int32 network_type = 200; 96 | // Reserved for network_port. 97 | reserved 201; 98 | optional uint64 time_connection_started_ms = 202; 99 | optional uint64 time_connection_ended_ms = 203; 100 | // Error code should be a net::Error value. 101 | optional int32 error_code = 204; 102 | 103 | // Fields for SUCCESSFUL_CONNECTION messages [300-399] 104 | optional uint64 time_connection_established_ms = 300; 105 | } 106 | 107 | /** 108 | TAG: 2 109 | */ 110 | message LoginRequest { 111 | enum AuthService { 112 | ANDROID_ID = 2; 113 | } 114 | required string id = 1; // Must be present ( proto required ), may be empty 115 | // string. 116 | // mcs.android.com. 117 | required string domain = 2; 118 | // Decimal android ID 119 | required string user = 3; 120 | 121 | required string resource = 4; 122 | 123 | // Secret 124 | required string auth_token = 5; 125 | 126 | // Format is: android-HEX_DEVICE_ID 127 | // The user is the decimal value. 128 | optional string device_id = 6; 129 | 130 | // RMQ1 - no longer used 131 | optional int64 last_rmq_id = 7; 132 | 133 | repeated Setting setting = 8; 134 | //optional int32 compress = 9; 135 | repeated string received_persistent_id = 10; 136 | 137 | // Replaced by "rmq2v" setting 138 | // optional bool include_stream_ids = 11; 139 | 140 | optional bool adaptive_heartbeat = 12; 141 | optional HeartbeatStat heartbeat_stat = 13; 142 | // Must be true. 143 | optional bool use_rmq2 = 14; 144 | optional int64 account_id = 15; 145 | 146 | // ANDROID_ID = 2 147 | optional AuthService auth_service = 16; 148 | 149 | optional int32 network_type = 17; 150 | optional int64 status = 18; 151 | 152 | // 19, 20, and 21 are not currently populated by Chrome. 153 | reserved 19, 20, 21; 154 | 155 | // Events recorded on the client after the last successful connection. 156 | repeated ClientEvent client_event = 22; 157 | } 158 | 159 | /** 160 | * TAG: 3 161 | */ 162 | message LoginResponse { 163 | required string id = 1; 164 | // Not used. 165 | optional string jid = 2; 166 | // Null if login was ok. 167 | optional ErrorInfo error = 3; 168 | repeated Setting setting = 4; 169 | optional int32 stream_id = 5; 170 | // Should be "1" 171 | optional int32 last_stream_id_received = 6; 172 | optional HeartbeatConfig heartbeat_config = 7; 173 | // used by the client to synchronize with the server timestamp. 174 | optional int64 server_timestamp = 8; 175 | } 176 | 177 | message StreamErrorStanza { 178 | required string type = 1; 179 | optional string text = 2; 180 | } 181 | 182 | /** 183 | * TAG: 4 184 | */ 185 | message Close { 186 | } 187 | 188 | message Extension { 189 | // 12: SelectiveAck 190 | // 13: StreamAck 191 | required int32 id = 1; 192 | required bytes data = 2; 193 | } 194 | 195 | /** 196 | * TAG: 7 197 | * IqRequest must contain a single extension. IqResponse may contain 0 or 1 198 | * extensions. 199 | */ 200 | message IqStanza { 201 | enum IqType { 202 | GET = 0; 203 | SET = 1; 204 | RESULT = 2; 205 | IQ_ERROR = 3; 206 | } 207 | 208 | optional int64 rmq_id = 1; 209 | required IqType type = 2; 210 | required string id = 3; 211 | optional string from = 4; 212 | optional string to = 5; 213 | optional ErrorInfo error = 6; 214 | 215 | // Only field used in the 38+ protocol (besides common last_stream_id_received, status, rmq_id) 216 | optional Extension extension = 7; 217 | 218 | optional string persistent_id = 8; 219 | optional int32 stream_id = 9; 220 | optional int32 last_stream_id_received = 10; 221 | optional int64 account_id = 11; 222 | optional int64 status = 12; 223 | } 224 | 225 | message AppData { 226 | required string key = 1; 227 | required string value = 2; 228 | } 229 | 230 | /** 231 | * TAG: 8 232 | */ 233 | message DataMessageStanza { 234 | // Not used. 235 | // optional int64 rmq_id = 1; 236 | 237 | // This is the message ID, set by client, DMP.9 (message_id) 238 | optional string id = 2; 239 | 240 | // Project ID of the sender, DMP.1 241 | required string from = 3; 242 | 243 | // Part of DMRequest - also the key in DataMessageProto. 244 | optional string to = 4; 245 | 246 | // Package name. DMP.2 247 | required string category = 5; 248 | 249 | // The collapsed key, DMP.3 250 | optional string token = 6; 251 | 252 | // User data + GOOGLE. prefixed special entries, DMP.4 253 | repeated AppData app_data = 7; 254 | 255 | // Not used. 256 | optional bool from_trusted_server = 8; 257 | 258 | // Part of the ACK protocol, returned in DataMessageResponse on server side. 259 | // It's part of the key of DMP. 260 | optional string persistent_id = 9; 261 | 262 | // In-stream ack. Increments on each message sent - a bit redundant 263 | // Not used in DMP/DMR. 264 | optional int32 stream_id = 10; 265 | optional int32 last_stream_id_received = 11; 266 | 267 | // Not used. 268 | // optional string permission = 12; 269 | 270 | // Sent by the device shortly after registration. 271 | optional string reg_id = 13; 272 | 273 | // Not used. 274 | // optional string pkg_signature = 14; 275 | // Not used. 276 | // optional string client_id = 15; 277 | 278 | // serial number of the target user, DMP.8 279 | // It is the 'serial number' according to user manager. 280 | optional int64 device_user_id = 16; 281 | 282 | // Time to live, in seconds. 283 | optional int32 ttl = 17; 284 | // Timestamp ( according to client ) when message was sent by app, in seconds 285 | optional int64 sent = 18; 286 | 287 | // How long has the message been queued before the flush, in seconds. 288 | // This is needed to account for the time difference between server and 289 | // client: server should adjust 'sent' based on its 'receive' time. 290 | optional int32 queued = 19; 291 | 292 | optional int64 status = 20; 293 | 294 | // Optional field containing the binary payload of the message. 295 | optional bytes raw_data = 21; 296 | 297 | // Not used. 298 | // The maximum delay of the message, in seconds. 299 | // optional int32 max_delay = 22; 300 | 301 | // Not used. 302 | // How long the message was delayed before it was sent, in seconds. 303 | // optional int32 actual_delay = 23; 304 | 305 | // If set the server requests immediate ack. Used for important messages and 306 | // for testing. 307 | optional bool immediate_ack = 24; 308 | 309 | // Not used. 310 | // Enables message receipts from MCS/GCM back to CCS clients 311 | // optional bool delivery_receipt_requested = 25; 312 | } 313 | 314 | /** 315 | Included in IQ with ID 13, sent from client or server after 10 unconfirmed 316 | messages. 317 | */ 318 | message StreamAck { 319 | // No last_streamid_received required. This is included within an IqStanza, 320 | // which includes the last_stream_id_received. 321 | } 322 | 323 | /** 324 | Included in IQ sent after LoginResponse from server with ID 12. 325 | */ 326 | message SelectiveAck { 327 | repeated string id = 1; 328 | } 329 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/token_retrieval.py: -------------------------------------------------------------------------------- 1 | # 2 | # GoogleFindMyTools - A set of tools to interact with the Google Find My API 3 | # Copyright © 2024 Leon Böttger. All rights reserved. 4 | # 5 | 6 | from __future__ import annotations 7 | 8 | import asyncio 9 | import logging 10 | import random 11 | from typing import Optional 12 | 13 | import gpsoauth 14 | 15 | from custom_components.googlefindmy.Auth.aas_token_retrieval import get_aas_token, async_get_aas_token 16 | from custom_components.googlefindmy.Auth.token_cache import ( 17 | get_cached_value, 18 | set_cached_value, 19 | async_get_cached_value, 20 | async_set_cached_value, 21 | ) 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | def _get_or_generate_android_id_sync(username: str) -> int: 27 | """Get or generate a unique Android ID for this user (synchronous version). 28 | 29 | Strategy: 30 | 1) Check cache for android_id_{username} 31 | 2) If not found, try to extract from fcm_credentials.gcm.android_id 32 | 3) If still not found, generate a random 64-bit Android ID 33 | 4) Store in cache and return 34 | 35 | Args: 36 | username: The Google account email. 37 | 38 | Returns: 39 | A 64-bit Android ID (int) unique to this user. 40 | """ 41 | cache_key = f"android_id_{username}" 42 | 43 | # Fast path: already cached 44 | try: 45 | cached_id = get_cached_value(cache_key) 46 | if cached_id is not None: 47 | try: 48 | return int(cached_id) 49 | except (ValueError, TypeError): 50 | _LOGGER.warning("Cached android_id for %s is invalid; will regenerate.", username) 51 | except Exception as e: # noqa: BLE001 52 | # Cache not available (multi-entry, validation, etc.) - continue to generation 53 | _LOGGER.debug("Cache not available for android_id lookup: %s. Will generate temporary Android ID.", e) 54 | 55 | # Try to extract from fcm_credentials 56 | try: 57 | fcm_creds = get_cached_value("fcm_credentials") 58 | if isinstance(fcm_creds, dict): 59 | try: 60 | android_id = fcm_creds.get("gcm", {}).get("android_id") 61 | if android_id is not None: 62 | android_id_int = int(android_id) 63 | _LOGGER.info("Extracted android_id from fcm_credentials for user %s: %s", username, hex(android_id_int)) 64 | try: 65 | set_cached_value(cache_key, android_id_int) 66 | except Exception: # noqa: BLE001 67 | # Can't cache; that's OK 68 | pass 69 | return android_id_int 70 | except (ValueError, TypeError, KeyError) as e: 71 | _LOGGER.debug("Failed to extract android_id from fcm_credentials: %s", e) 72 | except Exception: # noqa: BLE001 73 | # Cache not available; proceed to generation 74 | pass 75 | 76 | # Generate a new random Android ID (64-bit positive integer) 77 | new_id = random.randint(0x1000000000000000, 0xFFFFFFFFFFFFFFFF) 78 | _LOGGER.warning( 79 | "No android_id found for user %s; generated new random ID: %s. " 80 | "This may indicate the user needs to re-run GoogleFindMyTools to get fcm_credentials.", 81 | username, 82 | hex(new_id) 83 | ) 84 | try: 85 | set_cached_value(cache_key, new_id) 86 | except Exception: # noqa: BLE001 87 | # Can't cache; the ID will be regenerated properly after entry creation 88 | _LOGGER.debug("Cannot cache android_id; will cache after entry is created.") 89 | return new_id 90 | 91 | 92 | async def _get_or_generate_android_id_async(username: str) -> int: 93 | """Get or generate a unique Android ID for this user (async version). 94 | 95 | Strategy: 96 | 1) Check cache for android_id_{username} 97 | 2) If not found, try to extract from fcm_credentials.gcm.android_id 98 | 3) If still not found, generate a random 64-bit Android ID 99 | 4) Store in cache and return 100 | 101 | Args: 102 | username: The Google account email. 103 | 104 | Returns: 105 | A 64-bit Android ID (int) unique to this user. 106 | """ 107 | cache_key = f"android_id_{username}" 108 | 109 | # Fast path: already cached 110 | try: 111 | cached_id = await async_get_cached_value(cache_key) 112 | if cached_id is not None: 113 | try: 114 | return int(cached_id) 115 | except (ValueError, TypeError): 116 | _LOGGER.warning("Cached android_id for %s is invalid; will regenerate.", username) 117 | except Exception as e: # noqa: BLE001 118 | # Cache not available (multi-entry, validation, etc.) - continue to generation 119 | _LOGGER.debug("Cache not available for android_id lookup: %s. Will generate temporary Android ID.", e) 120 | 121 | # Try to extract from fcm_credentials 122 | try: 123 | fcm_creds = await async_get_cached_value("fcm_credentials") 124 | if isinstance(fcm_creds, dict): 125 | try: 126 | android_id = fcm_creds.get("gcm", {}).get("android_id") 127 | if android_id is not None: 128 | android_id_int = int(android_id) 129 | _LOGGER.info("Extracted android_id from fcm_credentials for user %s: %s", username, hex(android_id_int)) 130 | try: 131 | await async_set_cached_value(cache_key, android_id_int) 132 | except Exception: # noqa: BLE001 133 | # Can't cache during validation; that's OK 134 | pass 135 | return android_id_int 136 | except (ValueError, TypeError, KeyError) as e: 137 | _LOGGER.debug("Failed to extract android_id from fcm_credentials: %s", e) 138 | except Exception: # noqa: BLE001 139 | # Cache not available; proceed to generation 140 | pass 141 | 142 | # Generate a new random Android ID (64-bit positive integer) 143 | new_id = random.randint(0x1000000000000000, 0xFFFFFFFFFFFFFFFF) 144 | _LOGGER.warning( 145 | "No android_id found for user %s; generated new random ID: %s. " 146 | "This may indicate the user needs to re-run GoogleFindMyTools to get fcm_credentials.", 147 | username, 148 | hex(new_id) 149 | ) 150 | try: 151 | await async_set_cached_value(cache_key, new_id) 152 | except Exception: # noqa: BLE001 153 | # Can't cache during config flow validation; the ID will be regenerated properly after entry creation 154 | _LOGGER.debug("Cannot cache android_id during validation; will cache after entry is created.") 155 | return new_id 156 | 157 | 158 | def request_token(username: str, scope: str, play_services: bool = False) -> str: 159 | """Synchronous token request via gpsoauth (CLI/tests). 160 | 161 | WARNING: This is blocking. In Home Assistant use `await async_request_token(...)` 162 | or call this from an executor thread. 163 | """ 164 | aas_token = get_aas_token() # sync path (may block) 165 | 166 | # Get unique Android ID for this user 167 | android_id = _get_or_generate_android_id_sync(username) 168 | request_app = "com.google.android.gms" if play_services else "com.google.android.apps.adm" 169 | 170 | try: 171 | auth_response = gpsoauth.perform_oauth( 172 | username, 173 | aas_token, 174 | android_id, # Use per-user Android ID 175 | service="oauth2:https://www.googleapis.com/auth/" + scope, 176 | app=request_app, 177 | client_sig="38918a453d07199354f8b19af05ec6562ced5788", 178 | ) 179 | if not auth_response: 180 | raise ValueError("No response from gpsoauth.perform_oauth") 181 | 182 | if "Auth" not in auth_response: 183 | raise KeyError(f"'Auth' not found in response: {auth_response}") 184 | 185 | token = auth_response["Auth"] 186 | return token 187 | except Exception as e: 188 | raise RuntimeError(f"Failed to get auth token for scope '{scope}': {e}") from e 189 | 190 | 191 | async def async_request_token(username: str, scope: str, play_services: bool = False, cache: Optional[any] = None) -> str: 192 | """Async token request via gpsoauth (HA-safe). 193 | 194 | Uses async token retrieval and runs gpsoauth in a thread pool to avoid blocking the event loop. 195 | 196 | Args: 197 | cache: Optional TokenCache instance for multi-account isolation. 198 | """ 199 | aas_token = await async_get_aas_token(cache=cache) # async path 200 | 201 | # Get unique Android ID for this user 202 | android_id = await _get_or_generate_android_id_async(username) 203 | request_app = "com.google.android.gms" if play_services else "com.google.android.apps.adm" 204 | 205 | def _run() -> str: 206 | auth_response = gpsoauth.perform_oauth( 207 | username, 208 | aas_token, 209 | android_id, # Use per-user Android ID 210 | service="oauth2:https://www.googleapis.com/auth/" + scope, 211 | app=request_app, 212 | client_sig="38918a453d07199354f8b19af05ec6562ced5788", 213 | ) 214 | if not auth_response: 215 | raise ValueError("No response from gpsoauth.perform_oauth") 216 | 217 | if "Auth" not in auth_response: 218 | raise KeyError(f"'Auth' not found in response: {auth_response}") 219 | 220 | return auth_response["Auth"] 221 | 222 | loop = asyncio.get_running_loop() 223 | try: 224 | return await loop.run_in_executor(None, _run) 225 | except Exception as e: 226 | raise RuntimeError(f"Failed to get auth token for scope '{scope}': {e}") from e 227 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/Auth/firebase_messaging/proto/android_checkin_pb2.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | @generated by mypy-protobuf. Do not edit manually! 3 | isort:skip_file 4 | Copyright 2014 The Chromium Authors. All rights reserved. 5 | Use of this source code is governed by a BSD-style license that can be 6 | found in the LICENSE file. 7 | 8 | Logging information for Android "checkin" events (automatic, periodic 9 | requests made by Android devices to the server). 10 | """ 11 | 12 | import builtins 13 | import google.protobuf.descriptor 14 | import google.protobuf.internal.enum_type_wrapper 15 | import google.protobuf.message 16 | import sys 17 | import typing 18 | 19 | if sys.version_info >= (3, 10): 20 | import typing as typing_extensions 21 | else: 22 | import typing_extensions 23 | 24 | DESCRIPTOR: google.protobuf.descriptor.FileDescriptor 25 | 26 | class _DeviceType: 27 | ValueType = typing.NewType("ValueType", builtins.int) 28 | V: typing_extensions.TypeAlias = ValueType 29 | 30 | class _DeviceTypeEnumTypeWrapper( 31 | google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[_DeviceType.ValueType], 32 | builtins.type, 33 | ): 34 | DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor 35 | DEVICE_ANDROID_OS: _DeviceType.ValueType # 1 36 | """Android Device""" 37 | DEVICE_IOS_OS: _DeviceType.ValueType # 2 38 | """Apple IOS device""" 39 | DEVICE_CHROME_BROWSER: _DeviceType.ValueType # 3 40 | """Chrome browser - Not Chrome OS. No hardware records.""" 41 | DEVICE_CHROME_OS: _DeviceType.ValueType # 4 42 | """Chrome OS""" 43 | 44 | class DeviceType(_DeviceType, metaclass=_DeviceTypeEnumTypeWrapper): 45 | """enum values correspond to the type of device. 46 | Used in the AndroidCheckinProto and Device proto. 47 | """ 48 | 49 | DEVICE_ANDROID_OS: DeviceType.ValueType # 1 50 | """Android Device""" 51 | DEVICE_IOS_OS: DeviceType.ValueType # 2 52 | """Apple IOS device""" 53 | DEVICE_CHROME_BROWSER: DeviceType.ValueType # 3 54 | """Chrome browser - Not Chrome OS. No hardware records.""" 55 | DEVICE_CHROME_OS: DeviceType.ValueType # 4 56 | """Chrome OS""" 57 | global___DeviceType = DeviceType 58 | 59 | @typing_extensions.final 60 | class ChromeBuildProto(google.protobuf.message.Message): 61 | """Build characteristics unique to the Chrome browser, and Chrome OS""" 62 | 63 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 64 | 65 | class _Platform: 66 | ValueType = typing.NewType("ValueType", builtins.int) 67 | V: typing_extensions.TypeAlias = ValueType 68 | 69 | class _PlatformEnumTypeWrapper( 70 | google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[ 71 | ChromeBuildProto._Platform.ValueType 72 | ], 73 | builtins.type, 74 | ): 75 | DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor 76 | PLATFORM_WIN: ChromeBuildProto._Platform.ValueType # 1 77 | PLATFORM_MAC: ChromeBuildProto._Platform.ValueType # 2 78 | PLATFORM_LINUX: ChromeBuildProto._Platform.ValueType # 3 79 | PLATFORM_CROS: ChromeBuildProto._Platform.ValueType # 4 80 | PLATFORM_IOS: ChromeBuildProto._Platform.ValueType # 5 81 | PLATFORM_ANDROID: ChromeBuildProto._Platform.ValueType # 6 82 | """Just a placeholder. Likely don't need it due to the presence of the 83 | Android GCM on phone/tablet devices. 84 | """ 85 | 86 | class Platform(_Platform, metaclass=_PlatformEnumTypeWrapper): ... 87 | PLATFORM_WIN: ChromeBuildProto.Platform.ValueType # 1 88 | PLATFORM_MAC: ChromeBuildProto.Platform.ValueType # 2 89 | PLATFORM_LINUX: ChromeBuildProto.Platform.ValueType # 3 90 | PLATFORM_CROS: ChromeBuildProto.Platform.ValueType # 4 91 | PLATFORM_IOS: ChromeBuildProto.Platform.ValueType # 5 92 | PLATFORM_ANDROID: ChromeBuildProto.Platform.ValueType # 6 93 | """Just a placeholder. Likely don't need it due to the presence of the 94 | Android GCM on phone/tablet devices. 95 | """ 96 | 97 | class _Channel: 98 | ValueType = typing.NewType("ValueType", builtins.int) 99 | V: typing_extensions.TypeAlias = ValueType 100 | 101 | class _ChannelEnumTypeWrapper( 102 | google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[ 103 | ChromeBuildProto._Channel.ValueType 104 | ], 105 | builtins.type, 106 | ): 107 | DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor 108 | CHANNEL_STABLE: ChromeBuildProto._Channel.ValueType # 1 109 | CHANNEL_BETA: ChromeBuildProto._Channel.ValueType # 2 110 | CHANNEL_DEV: ChromeBuildProto._Channel.ValueType # 3 111 | CHANNEL_CANARY: ChromeBuildProto._Channel.ValueType # 4 112 | CHANNEL_UNKNOWN: ChromeBuildProto._Channel.ValueType # 5 113 | """for tip of tree or custom builds""" 114 | 115 | class Channel(_Channel, metaclass=_ChannelEnumTypeWrapper): ... 116 | CHANNEL_STABLE: ChromeBuildProto.Channel.ValueType # 1 117 | CHANNEL_BETA: ChromeBuildProto.Channel.ValueType # 2 118 | CHANNEL_DEV: ChromeBuildProto.Channel.ValueType # 3 119 | CHANNEL_CANARY: ChromeBuildProto.Channel.ValueType # 4 120 | CHANNEL_UNKNOWN: ChromeBuildProto.Channel.ValueType # 5 121 | """for tip of tree or custom builds""" 122 | 123 | PLATFORM_FIELD_NUMBER: builtins.int 124 | CHROME_VERSION_FIELD_NUMBER: builtins.int 125 | CHANNEL_FIELD_NUMBER: builtins.int 126 | platform: global___ChromeBuildProto.Platform.ValueType 127 | """The platform of the device.""" 128 | chrome_version: builtins.str 129 | """The Chrome instance's version.""" 130 | channel: global___ChromeBuildProto.Channel.ValueType 131 | """The Channel (build type) of Chrome.""" 132 | def __init__( 133 | self, 134 | *, 135 | platform: global___ChromeBuildProto.Platform.ValueType | None = ..., 136 | chrome_version: builtins.str | None = ..., 137 | channel: global___ChromeBuildProto.Channel.ValueType | None = ..., 138 | ) -> None: ... 139 | def HasField( 140 | self, 141 | field_name: typing_extensions.Literal[ 142 | "channel", 143 | b"channel", 144 | "chrome_version", 145 | b"chrome_version", 146 | "platform", 147 | b"platform", 148 | ], 149 | ) -> builtins.bool: ... 150 | def ClearField( 151 | self, 152 | field_name: typing_extensions.Literal[ 153 | "channel", 154 | b"channel", 155 | "chrome_version", 156 | b"chrome_version", 157 | "platform", 158 | b"platform", 159 | ], 160 | ) -> None: ... 161 | 162 | global___ChromeBuildProto = ChromeBuildProto 163 | 164 | @typing_extensions.final 165 | class AndroidCheckinProto(google.protobuf.message.Message): 166 | """Information sent by the device in a "checkin" request.""" 167 | 168 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 169 | 170 | LAST_CHECKIN_MSEC_FIELD_NUMBER: builtins.int 171 | CELL_OPERATOR_FIELD_NUMBER: builtins.int 172 | SIM_OPERATOR_FIELD_NUMBER: builtins.int 173 | ROAMING_FIELD_NUMBER: builtins.int 174 | USER_NUMBER_FIELD_NUMBER: builtins.int 175 | TYPE_FIELD_NUMBER: builtins.int 176 | CHROME_BUILD_FIELD_NUMBER: builtins.int 177 | last_checkin_msec: builtins.int 178 | """Miliseconds since the Unix epoch of the device's last successful checkin.""" 179 | cell_operator: builtins.str 180 | """The current MCC+MNC of the mobile device's current cell.""" 181 | sim_operator: builtins.str 182 | """The MCC+MNC of the SIM card (different from operator if the 183 | device is roaming, for instance). 184 | """ 185 | roaming: builtins.str 186 | """The device's current roaming state (reported starting in eclair builds). 187 | Currently one of "{,not}mobile-{,not}roaming", if it is present at all. 188 | """ 189 | user_number: builtins.int 190 | """For devices supporting multiple user profiles (which may be 191 | supported starting in jellybean), the ordinal number of the 192 | profile that is checking in. This is 0 for the primary profile 193 | (which can't be changed without wiping the device), and 1,2,3,... 194 | for additional profiles (which can be added and deleted freely). 195 | """ 196 | type: global___DeviceType.ValueType 197 | """Class of device. Indicates the type of build proto 198 | (IosBuildProto/ChromeBuildProto/AndroidBuildProto) 199 | That is included in this proto 200 | """ 201 | @property 202 | def chrome_build(self) -> global___ChromeBuildProto: 203 | """For devices running MCS on Chrome, build-specific characteristics 204 | of the browser. There are no hardware aspects (except for ChromeOS). 205 | This will only be populated for Chrome builds/ChromeOS devices 206 | """ 207 | def __init__( 208 | self, 209 | *, 210 | last_checkin_msec: builtins.int | None = ..., 211 | cell_operator: builtins.str | None = ..., 212 | sim_operator: builtins.str | None = ..., 213 | roaming: builtins.str | None = ..., 214 | user_number: builtins.int | None = ..., 215 | type: global___DeviceType.ValueType | None = ..., 216 | chrome_build: global___ChromeBuildProto | None = ..., 217 | ) -> None: ... 218 | def HasField( 219 | self, 220 | field_name: typing_extensions.Literal[ 221 | "cell_operator", 222 | b"cell_operator", 223 | "chrome_build", 224 | b"chrome_build", 225 | "last_checkin_msec", 226 | b"last_checkin_msec", 227 | "roaming", 228 | b"roaming", 229 | "sim_operator", 230 | b"sim_operator", 231 | "type", 232 | b"type", 233 | "user_number", 234 | b"user_number", 235 | ], 236 | ) -> builtins.bool: ... 237 | def ClearField( 238 | self, 239 | field_name: typing_extensions.Literal[ 240 | "cell_operator", 241 | b"cell_operator", 242 | "chrome_build", 243 | b"chrome_build", 244 | "last_checkin_msec", 245 | b"last_checkin_msec", 246 | "roaming", 247 | b"roaming", 248 | "sim_operator", 249 | b"sim_operator", 250 | "type", 251 | b"type", 252 | "user_number", 253 | b"user_number", 254 | ], 255 | ) -> None: ... 256 | 257 | global___AndroidCheckinProto = AndroidCheckinProto 258 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Google Find My Device", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Google Find My Device Authentication", 7 | "description": "Choose your authentication method. Method 1 is recommended if you can run GoogleFindMyTools on a computer with Chrome.", 8 | "data": { 9 | "auth_method": "Authentication Method" 10 | } 11 | }, 12 | "secrets_json": { 13 | "title": "Method 1: GoogleFindMyTools secrets.json", 14 | "description": "Run GoogleFindMyTools (Chrome) to generate authentication tokens, then paste the full contents of the Auth/secrets.json file below.", 15 | "data": { 16 | "secrets_json": "Contents of secrets.json file" 17 | } 18 | }, 19 | "individual_tokens": { 20 | "title": "Method 2: Individual OAuth Token", 21 | "description": "Enter your OAuth token and Google email address obtained from a manual authentication process.", 22 | "data": { 23 | "oauth_token": "OAuth Token", 24 | "google_email": "Google Email Address" 25 | } 26 | }, 27 | "device_selection": { 28 | "title": "Select Devices and Polling", 29 | "description": "Newly discovered devices appear disabled and can be enabled in device settings. Configure the general polling parameters:\n• Location poll interval: How often to start a new polling cycle (60–3600 seconds)\n• Device poll delay: Delay between individual device polls within a cycle (1–60 seconds)", 30 | "data": { 31 | "location_poll_interval": "Location poll interval (s)", 32 | "device_poll_delay": "Device poll delay (s)", 33 | "min_accuracy_threshold": "Minimum accuracy (m)", 34 | "movement_threshold": "Movement threshold (m)", 35 | "google_home_filter_enabled": "Filter Google Home devices", 36 | "google_home_filter_keywords": "Filter keywords (comma-separated)", 37 | "enable_stats_entities": "Create statistics entities", 38 | "map_view_token_expiration": "Enable map view token expiration" 39 | } 40 | }, 41 | "reauth_confirm": { 42 | "title": "Reauthenticate", 43 | "description": "Your credentials appear to be invalid or expired. Please provide new credentials below.\n\nTip: You can either paste the full contents of a fresh secrets.json **or** supply a new OAuth token and email." 44 | } 45 | }, 46 | "error": { 47 | "auth_failed": "Authentication failed. Please try again.", 48 | "invalid_token": "Invalid token/email provided or required fields are missing.", 49 | "invalid_json": "Invalid JSON format in secrets.json content.", 50 | "no_devices": "No devices found. Please ensure that Find My Device is enabled on your Google account.", 51 | "cannot_connect": "Failed to connect to the Google API. This may be temporary — please try again later.", 52 | "choose_one": "Please provide exactly one credential method.", 53 | "required": "This field is required.", 54 | "unknown": "Unexpected error occurred." 55 | }, 56 | "abort": { 57 | "already_configured": "Google Find My Device is already configured.", 58 | "cannot_connect": "Failed to connect to Google Find My Device.", 59 | "reauth_successful": "Reauthentication successful." 60 | } 61 | }, 62 | "options": { 63 | "step": { 64 | "init": { 65 | "title": "Configuration", 66 | "description": "What would you like to configure?", 67 | "menu_options": { 68 | "credentials": "Update credentials", 69 | "settings": "Modify settings", 70 | "visibility": "Device visibility" 71 | } 72 | }, 73 | "credentials": { 74 | "title": "Update Credentials (optional)", 75 | "description": "Update credentials without exposing existing values. Provide exactly one method:\n\n• Paste full contents of a new **secrets.json**\n**or**\n• Enter a new **OAuth token** and **Google email**.\n\nIf provided, credentials will be validated and stored; the integration will reload without changing your entity names.", 76 | "data": { 77 | "secrets_json": "New secrets.json (full contents)", 78 | "oauth_token": "New OAuth token", 79 | "google_email": "Google email", 80 | "new_secrets_json": "New secrets.json (full contents)", 81 | "new_oauth_token": "New OAuth token", 82 | "new_google_email": "Google email" 83 | } 84 | }, 85 | "settings": { 86 | "title": "Options", 87 | "description": "Adjust location settings:\n• Location poll interval: How often to poll for locations (60–3600 seconds)\n• Device poll delay: Delay between device polls (1–60 seconds)\n• Minimum accuracy: Ignore locations with an accuracy worse than this value (25–500 meters)\n• Movement threshold: Minimum movement required to trigger a location update (10–200 meters)\n\nGoogle Home Filter:\n• Enable to associate detections from Google Home devices with the Home zone\n• Keywords support partial matching (comma-separated)\n• Example: “nest” matches “Kitchen Nest Mini”", 88 | "data": { 89 | "location_poll_interval": "Location poll interval (s)", 90 | "device_poll_delay": "Device poll delay (s)", 91 | "min_accuracy_threshold": "Minimum accuracy (m)", 92 | "movement_threshold": "Movement threshold (m)", 93 | "google_home_filter_enabled": "Filter Google Home devices", 94 | "google_home_filter_keywords": "Filter keywords (comma-separated)", 95 | "enable_stats_entities": "Create statistics entities", 96 | "map_view_token_expiration": "Enable map view token expiration" 97 | }, 98 | "data_description": { 99 | "map_view_token_expiration": "When enabled, map view tokens expire after 1 week. When disabled (default), tokens do not expire." 100 | } 101 | }, 102 | "visibility": { 103 | "title": "Device Visibility", 104 | "description": "Restore previously ignored devices to make them visible again. Select one or more devices to restore and save.", 105 | "data": { 106 | "unignore_devices": "Devices to restore" 107 | } 108 | } 109 | }, 110 | "error": { 111 | "invalid_json": "Invalid JSON format in secrets.json content.", 112 | "invalid": "Validation failed. Please check your input.", 113 | "cannot_connect": "Failed to connect to the Google API.", 114 | "choose_one": "Please provide exactly one credential method.", 115 | "required": "This field is required.", 116 | "invalid_token": "Invalid credentials (token/email). Please check format and content." 117 | }, 118 | "abort": { 119 | "reconfigure_successful": "Reconfiguration successful.", 120 | "no_ignored_devices": "There are no ignored devices to restore." 121 | } 122 | }, 123 | "services": { 124 | "locate_device": { 125 | "name": "Locate Device", 126 | "description": "Get the current location of a Google Find My device.", 127 | "fields": { 128 | "device_id": { 129 | "name": "Device", 130 | "description": "Select a Google Find My device (or pass a canonical ID via Developer Tools)." 131 | } 132 | } 133 | }, 134 | "play_sound": { 135 | "name": "Play Sound", 136 | "description": "Play a sound on a Google Find My device.", 137 | "fields": { 138 | "device_id": { 139 | "name": "Device", 140 | "description": "Select a Google Find My device (or pass a canonical ID via Developer Tools)." 141 | } 142 | } 143 | }, 144 | "stop_sound": { 145 | "name": "Stop Sound", 146 | "description": "Stop the sound on a Google Find My device.", 147 | "fields": { 148 | "device_id": { 149 | "name": "Device", 150 | "description": "Select a Google Find My device (or pass a canonical ID via Developer Tools)." 151 | } 152 | } 153 | }, 154 | "refresh_device_urls": { 155 | "name": "Refresh Device URLs", 156 | "description": "Refresh the configuration URLs for Google Find My devices to point to the integrated map view.", 157 | "fields": {} 158 | }, 159 | "locate_device_external": { 160 | "name": "Locate Device (External)", 161 | "description": "Locate a device using the external helper; delegates to the normal locate operation.", 162 | "fields": { 163 | "device_id": { 164 | "name": "Device", 165 | "description": "Select a Google Find My device (or pass a canonical ID via Developer Tools)." 166 | }, 167 | "device_name": { 168 | "name": "Device Name (optional)", 169 | "description": "An optional human-readable name for logging." 170 | } 171 | } 172 | }, 173 | "rebuild_registry": { 174 | "name": "Rebuild / Migrate Registry", 175 | "description": "Maintenance: Run a soft data→options migration or rebuild entities/devices for this integration.", 176 | "fields": { 177 | "mode": { 178 | "name": "Mode", 179 | "description": "Choose 'Rebuild' to remove entities/devices and reload; 'Migrate' only re-applies the soft data→options migration." 180 | }, 181 | "device_ids": { 182 | "name": "Devices (optional)", 183 | "description": "Limit the operation to specific devices. Leave empty to target all Google Find My devices." 184 | } 185 | } 186 | } 187 | }, 188 | "entity": { 189 | "button": { 190 | "play_sound": { 191 | "name": "Play sound" 192 | }, 193 | "stop_sound": { 194 | "name": "Stop sound" 195 | }, 196 | "locate_device": { 197 | "name": "Locate now" 198 | } 199 | }, 200 | "binary_sensor": { 201 | "polling": { 202 | "name": "Polling" 203 | } 204 | }, 205 | "sensor": { 206 | "last_seen": { 207 | "name": "Last Seen" 208 | }, 209 | "stat_background_updates": { 210 | "name": "Background updates" 211 | }, 212 | "stat_polled_updates": { 213 | "name": "Polled updates" 214 | }, 215 | "stat_crowd_sourced_updates": { 216 | "name": "Crowd-sourced updates" 217 | }, 218 | "stat_history_fallback_used": { 219 | "name": "Historical fallbacks" 220 | }, 221 | "stat_timeouts": { 222 | "name": "Timeouts" 223 | }, 224 | "stat_invalid_coords": { 225 | "name": "Invalid coordinates" 226 | }, 227 | "stat_low_quality_dropped": { 228 | "name": "Low-accuracy dropped" 229 | }, 230 | "stat_non_significant_dropped": { 231 | "name": "Non-significant updates dropped" 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /custom_components/googlefindmy/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Google Find My Device", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Uwierzytelnianie Google Find My Device", 7 | "description": "Wybierz metodę uwierzytelniania. Metoda 1 jest zalecana, jeśli możesz uruchomić GoogleFindMyTools na komputerze z przeglądarką Chrome.", 8 | "data": { 9 | "auth_method": "Metoda uwierzytelniania" 10 | } 11 | }, 12 | "secrets_json": { 13 | "title": "Metoda 1: GoogleFindMyTools secrets.json", 14 | "description": "Uruchom GoogleFindMyTools (Chrome), aby wygenerować tokeny uwierzytelniające, a następnie wklej poniżej pełną zawartość pliku Auth/secrets.json.", 15 | "data": { 16 | "secrets_json": "Zawartość pliku secrets.json" 17 | } 18 | }, 19 | "individual_tokens": { 20 | "title": "Metoda 2: Indywidualny token OAuth", 21 | "description": "Wprowadź token OAuth i adres e-mail Google uzyskane podczas ręcznej autoryzacji.", 22 | "data": { 23 | "oauth_token": "Token OAuth", 24 | "google_email": "Adres e-mail Google" 25 | } 26 | }, 27 | "device_selection": { 28 | "title": "Wybór urządzeń i odpytywania", 29 | "description": "Nowo wykryte urządzenia pojawiają się jako wyłączone i można je włączyć w ustawieniach urządzenia. Skonfiguruj tutaj ogólne parametry odpytywania:\n• Interwał odpytywania lokalizacji: jak często rozpoczyna się nowy cykl (60–3600 sekund)\n• Opóźnienie między odpytywaniem urządzeń: odstęp między zapytaniami dla poszczególnych urządzeń w cyklu (1–60 sekund)", 30 | "data": { 31 | "location_poll_interval": "Interwał odpytywania lokalizacji (s)", 32 | "device_poll_delay": "Opóźnienie między odpytywaniem urządzeń (s)", 33 | "min_accuracy_threshold": "Minimalna dokładność (m)", 34 | "movement_threshold": "Próg ruchu (m)", 35 | "google_home_filter_enabled": "Filtruj urządzenia Google Home", 36 | "google_home_filter_keywords": "Słowa kluczowe filtra (oddzielone przecinkami)", 37 | "enable_stats_entities": "Twórz encje statystyczne", 38 | "map_view_token_expiration": "Włącz wygasanie tokenu widoku mapy" 39 | } 40 | }, 41 | "reauth_confirm": { 42 | "title": "Ponowne uwierzytelnienie", 43 | "description": "Twoje poświadczenia są nieprawidłowe lub wygasły. Podaj poniżej nowe.\n\nWskazówka: możesz wkleić pełną zawartość nowego pliku secrets.json **albo** podać nowy token OAuth i adres e-mail." 44 | } 45 | }, 46 | "error": { 47 | "auth_failed": "Uwierzytelnianie nie powiodło się. Spróbuj ponownie.", 48 | "invalid_token": "Nieprawidłowy token/e-mail lub brakuje wymaganych pól.", 49 | "invalid_json": "Nieprawidłowy format JSON w zawartości pliku secrets.json.", 50 | "no_devices": "Nie znaleziono urządzeń. Upewnij się, że „Znajdź moje urządzenie” jest włączone na Twoim koncie Google.", 51 | "cannot_connect": "Nie udało się połączyć z interfejsem Google API. Może to być problem tymczasowy — spróbuj ponownie później.", 52 | "choose_one": "Podaj dokładnie jedną metodę poświadczeń.", 53 | "required": "To pole jest wymagane.", 54 | "unknown": "Wystąpił nieoczekiwany błąd." 55 | }, 56 | "abort": { 57 | "already_configured": "Google Find My Device jest już skonfigurowane.", 58 | "cannot_connect": "Nie udało się połączyć z Google Find My Device.", 59 | "reauth_successful": "Ponowne uwierzytelnienie zakończone pomyślnie." 60 | } 61 | }, 62 | "options": { 63 | "step": { 64 | "init": { 65 | "title": "Konfiguracja", 66 | "description": "Co chcesz skonfigurować?", 67 | "menu_options": { 68 | "credentials": "Zaktualizuj poświadczenia", 69 | "settings": "Zmień ustawienia", 70 | "visibility": "Widoczność urządzeń" 71 | } 72 | }, 73 | "credentials": { 74 | "title": "Aktualizuj poświadczenia (opcjonalnie)", 75 | "description": "Zaktualizuj poświadczenia bez ujawniania istniejących wartości. Podaj dokładnie jedną metodę:\n\n• Wklej pełną zawartość nowego **secrets.json**\n**albo**\n• Podaj nowy **token OAuth** i **adres e-mail Google**.\n\nJeśli podasz nowe dane, zostaną one zweryfikowane i zapisane; integracja przeładuje się bez zmiany nazw Twoich encji.", 76 | "data": { 77 | "secrets_json": "Nowy secrets.json (pełna zawartość)", 78 | "oauth_token": "Nowy token OAuth", 79 | "google_email": "Adres e-mail Google", 80 | "new_secrets_json": "Nowy plik secrets.json (pełna zawartość)", 81 | "new_oauth_token": "Nowy token OAuth", 82 | "new_google_email": "Nowy adres e-mail Google" 83 | } 84 | }, 85 | "settings": { 86 | "title": "Opcje", 87 | "description": "Dostosuj ustawienia lokalizacji:\n• Interwał odpytywania lokalizacji: jak często pobierać pozycje (60–3600 sekund)\n• Opóźnienie między odpytywaniem urządzeń: odstęp między zapytaniami (1–60 sekund)\n• Minimalna dokładność: ignoruj pozycje gorsze niż ta wartość (25–500 metrów)\n• Próg ruchu: minimalny ruch wyzwalający aktualizację pozycji (10–200 metrów)\n\nFiltr Google Home:\n• Włącz, aby grupować wykrycia Google Home w strefie „Dom”\n• Słowa kluczowe wspierają dopasowania częściowe (oddzielone przecinkami)\n• Przykład: „nest” pasuje do „Kitchen Nest Mini”", 88 | "data": { 89 | "location_poll_interval": "Interwał odpytywania lokalizacji (s)", 90 | "device_poll_delay": "Opóźnienie między odpytywaniem urządzeń (s)", 91 | "min_accuracy_threshold": "Minimalna dokładność (m)", 92 | "movement_threshold": "Próg ruchu (m)", 93 | "google_home_filter_enabled": "Filtruj urządzenia Google Home", 94 | "google_home_filter_keywords": "Słowa kluczowe filtra (oddzielone przecinkami)", 95 | "enable_stats_entities": "Twórz encje statystyczne", 96 | "map_view_token_expiration": "Włącz wygasanie tokenu widoku mapy" 97 | }, 98 | "data_description": { 99 | "map_view_token_expiration": "Po włączeniu tokeny widoku mapy wygasają po 1 tygodniu. Po wyłączeniu (domyślnie) nie wygasają." 100 | } 101 | }, 102 | "visibility": { 103 | "title": "Widoczność urządzeń", 104 | "description": "Przywróć wcześniej ignorowane urządzenia, aby znów były widoczne. Wybierz jedno lub więcej urządzeń i zapisz.", 105 | "data": { 106 | "unignore_devices": "Przywróć urządzenia" 107 | } 108 | } 109 | }, 110 | "error": { 111 | "invalid_json": "Nieprawidłowy format JSON w zawartości pliku secrets.json.", 112 | "invalid": "Walidacja nie powiodła się. Sprawdź swoje dane.", 113 | "cannot_connect": "Nie udało się połączyć z interfejsem Google API.", 114 | "choose_one": "Podaj dokładnie jedną metodę poświadczeń.", 115 | "required": "To pole jest wymagane.", 116 | "invalid_token": "Nieprawidłowe dane logowania (token/e-mail). Sprawdź format i zawartość." 117 | }, 118 | "abort": { 119 | "reconfigure_successful": "Ponowna konfiguracja zakończona pomyślnie.", 120 | "no_ignored_devices": "Brak ignorowanych urządzeń do przywrócenia." 121 | } 122 | }, 123 | "services": { 124 | "locate_device": { 125 | "name": "Zlokalizuj urządzenie", 126 | "description": "Pobierz bieżącą lokalizację urządzenia Google Find My.", 127 | "fields": { 128 | "device_id": { 129 | "name": "Urządzenie", 130 | "description": "Wybierz urządzenie Google Find My (lub przekaż kanoniczny identyfikator przez Narzędzia deweloperskie)." 131 | } 132 | } 133 | }, 134 | "play_sound": { 135 | "name": "Odtwórz dźwięk", 136 | "description": "Odtwórz dźwięk na urządzeniu Google Find My.", 137 | "fields": { 138 | "device_id": { 139 | "name": "Urządzenie", 140 | "description": "Wybierz urządzenie Google Find My (lub przekaż kanoniczny identyfikator przez Narzędzia deweloperskie)." 141 | } 142 | } 143 | }, 144 | "stop_sound": { 145 | "name": "Zatrzymaj dźwięk", 146 | "description": "Zatrzymuje dźwięk na urządzeniu Google Find My.", 147 | "fields": { 148 | "device_id": { 149 | "name": "Urządzenie", 150 | "description": "Wybierz urządzenie Google Find My (lub przekaż kanoniczny identyfikator przez Narzędzia deweloperskie)." 151 | } 152 | } 153 | }, 154 | "refresh_device_urls": { 155 | "name": "Odśwież adresy URL urządzeń", 156 | "description": "Odświeża adresy URL konfiguracji urządzeń Google Find My tak, aby wskazywały na zintegrowany widok mapy.", 157 | "fields": {} 158 | }, 159 | "locate_device_external": { 160 | "name": "Zlokalizuj urządzenie (zewnętrznie)", 161 | "description": "Zlokalizuj urządzenie przy użyciu zewnętrznego pomocnika; deleguje do zwykłej lokalizacji.", 162 | "fields": { 163 | "device_id": { 164 | "name": "Urządzenie", 165 | "description": "Wybierz urządzenie Google Find My (lub przekaż kanoniczny identyfikator przez Narzędzia deweloperskie)." 166 | }, 167 | "device_name": { 168 | "name": "Nazwa urządzenia (opcjonalnie)", 169 | "description": "Opcjonalna, czytelna nazwa używana wyłącznie w logach." 170 | } 171 | } 172 | }, 173 | "rebuild_registry": { 174 | "name": "Odbuduj / Migruj rejestr", 175 | "description": "Konserwacja: wykonaj miękką migrację dane→opcje lub odbuduj encje/urządzenia dla tej integracji.", 176 | "fields": { 177 | "mode": { 178 | "name": "Tryb", 179 | "description": "Wybierz „Odbuduj”, aby usunąć encje/urządzenia i przeładować; „Migruj” ponownie zastosuje jedynie miękką migrację dane→opcje." 180 | }, 181 | "device_ids": { 182 | "name": "Urządzenia (opcjonalnie)", 183 | "description": "Ogranicz operację do wybranych urządzeń. Pozostaw puste, aby objąć wszystkie urządzenia Google Find My." 184 | } 185 | } 186 | } 187 | }, 188 | "entity": { 189 | "button": { 190 | "play_sound": { 191 | "name": "Odtwórz dźwięk" 192 | }, 193 | "stop_sound": { 194 | "name": "Zatrzymaj dźwięk" 195 | }, 196 | "locate_device": { 197 | "name": "Zlokalizuj teraz" 198 | } 199 | }, 200 | "binary_sensor": { 201 | "polling": { 202 | "name": "Odpytywanie" 203 | } 204 | }, 205 | "sensor": { 206 | "last_seen": { 207 | "name": "Ostatnio widziany" 208 | }, 209 | "stat_background_updates": { 210 | "name": "Aktualizacje w tle" 211 | }, 212 | "stat_polled_updates": { 213 | "name": "Aktualizacje z odpytywania" 214 | }, 215 | "stat_crowd_sourced_updates": { 216 | "name": "Aktualizacje społecznościowe" 217 | }, 218 | "stat_history_fallback_used": { 219 | "name": "Użyto danych z historii" 220 | }, 221 | "stat_timeouts": { 222 | "name": "Przekroczenia czasu" 223 | }, 224 | "stat_invalid_coords": { 225 | "name": "Nieprawidłowe współrzędne" 226 | }, 227 | "stat_low_quality_dropped": { 228 | "name": "Odrzucono zbyt niską dokładność" 229 | }, 230 | "stat_non_significant_dropped": { 231 | "name": "Odrzucone nieistotne aktualizacje" 232 | } 233 | } 234 | } 235 | } 236 | --------------------------------------------------------------------------------