├── tests ├── __init__.py └── test_encrypt.py ├── src ├── __init__.py ├── errors.py ├── helpers.py ├── cli.py ├── constants.py └── zepp.py ├── .gitignore ├── main.py ├── LICENSE.md ├── pyproject.toml ├── .woodpecker.yml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | notebooks 2 | .idea 3 | .DS_Store 4 | *.zip 5 | __pycache__ 6 | *.dump 7 | *.fw 8 | *.bin 9 | venv 10 | .mypy_cache 11 | dist 12 | akps/ 13 | frida-js/ -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Command-line entry point for huami-token.""" 3 | import sys 4 | from src.cli import main 5 | 6 | if __name__ == "__main__": 7 | sys.exit(main()) 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2025 Kirill Snezhko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "huami_token" 3 | version = "0.8.0" 4 | description = "This script retrieves the Bluetooth access token for the watch or band from Huami servers. Additionally, it downloads the AGPS data packs, cep_alm_pak.zip and cep_7days.zip." 5 | readme = "README.md" 6 | requires-python = ">=3.8" 7 | authors = [ 8 | { name = "Kirill Snezhko", email = "kirill.snezhko@pm.me" }, 9 | ] 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | ] 15 | 16 | dependencies = [ 17 | "loguru>=0.7.3", 18 | "pycryptodome>=3.23.0", 19 | "requests>=2.31.0,<3", 20 | ] 21 | 22 | [project.optional-dependencies] 23 | dev = [ 24 | "pytest>=8,<9", 25 | "pytest-flake8>=1.1.3", 26 | "pytest-pylint>=0.20.0", 27 | "types-requests>=2.31.0.20231231", 28 | "ruff>=0.5,<1", 29 | "mypy>=1.10,<2", 30 | ] 31 | 32 | [project.urls] 33 | "Homepage" = "https://codeberg.org/argrento/huami-token" 34 | "Bug Tracker" = "https://codeberg.org/argrento/huami-token/issues" 35 | 36 | [build-system] 37 | requires = ["hatchling>=1.25"] 38 | build-backend = "hatchling.build" 39 | 40 | [tool.hatch.build.targets.wheel] 41 | packages = ["huami_token"] 42 | 43 | [tool.ruff] 44 | line-length = 100 45 | target-version = "py311" 46 | 47 | [tool.mypy] 48 | python_version = "3.11" 49 | warn_unused_ignores = true 50 | warn_redundant_casts = true 51 | -------------------------------------------------------------------------------- /tests/test_encrypt.py: -------------------------------------------------------------------------------- 1 | from src.helpers import zepp_encrypt_payload, zepp_decrypt_payload 2 | import pytest 3 | 4 | 5 | def test_zepp_encrypt_decrypt() -> None: 6 | key = b"xeNtBVqzDc6tuNTh" 7 | iv = b"MAAAYAAAAAAAAABg" 8 | 9 | raw_payload = ( 10 | b"""emailOrPhone=a%40a.com&state=REDIRECTION&client_id=HuaMi&password=a&""" 11 | b"""redirect_uri=https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fhm-registration%2Fsuccesssignin.html&""" 12 | b"""region=us-west-2&token=access&token=refresh&country_code=US""" 13 | ) 14 | 15 | captured_data = ( 16 | b"""[\x06\x02\xa5R\xf5\x99\xb6\xa8|\xd3D\x987\xcac\xb5<""" 22 | b"""\xfd\xba\xdf\xec\xfe\x08\xba\x9c\xafe\xf9\x03Q\x1fM\x84\xa3\x883\x8f\xc4\xb6""" 23 | ) 24 | 25 | encrypted_data = zepp_encrypt_payload(raw_payload, key, iv) 26 | decrypted_data = zepp_decrypt_payload(captured_data, key, iv) 27 | 28 | assert encrypted_data == captured_data 29 | assert raw_payload == decrypted_data 30 | -------------------------------------------------------------------------------- /src/errors.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 Kirill Snezhko 2 | # MIT License 3 | 4 | """Module for storing error messages""" 5 | 6 | from __future__ import annotations 7 | 8 | 9 | class MigrationInProgressError(Exception): 10 | """Exception raised when a non-implemented feature is called..""" 11 | 12 | def __init__(self, message: str | None = None): 13 | self.message = message or ( 14 | "This feature is not yet reverse-engineered. " 15 | "Track progress here: https://codeberg.org/argrento/huami-token/issues/119" 16 | ) 17 | super().__init__(self.message) 18 | 19 | 20 | class HuamiTokenError(Exception): 21 | """Base class for exceptions in Zepp/Amazfit module.""" 22 | 23 | pass 24 | 25 | 26 | class AuthenticationError(HuamiTokenError): 27 | """Exception raised for authentication errors.""" 28 | 29 | def __init__(self, code: str | None = None, message: str | None = None): 30 | self.code = code 31 | self.message = message or f"Authentication failed (code={code})" 32 | super().__init__(self.message) 33 | 34 | 35 | class LogoutError(HuamiTokenError): 36 | """Exception raised for logout errors.""" 37 | 38 | def __init__(self, code: str | None = None, message: str | None = None): 39 | self.code = code 40 | self.message = message or f"Logout failed (code={code})" 41 | super().__init__(self.message) 42 | 43 | 44 | class DeviceError(HuamiTokenError): 45 | """Exception raised for device-related errors.""" 46 | 47 | def __init__(self, code: str | None = None, message: str | None = None): 48 | self.code = code 49 | self.message = message or f"Device error (code={code})" 50 | super().__init__(self.message) 51 | -------------------------------------------------------------------------------- /.woodpecker.yml: -------------------------------------------------------------------------------- 1 | pipeline: 2 | style_check: 3 | image: python:3.9-buster 4 | # when: 5 | # event: pull_request 6 | commands: 7 | - python -m pip install --upgrade pip 8 | - python -m pip install -r requirements.txt 9 | - python -m pip install pylint flake8 mypy>=0.971 10 | - python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 11 | - mypy --strict ./ 12 | - python -m pylint -f parseable src/*.py 13 | 14 | unit_tests: 15 | image: python:${TAG}-buster 16 | # when: 17 | # event: pull_request 18 | commands: 19 | - ls 20 | - python -m venv venv 21 | - /bin/bash -c "source venv/bin/activate" 22 | - python -m pip install --upgrade pip 23 | - python -m pip install -r requirements.txt 24 | - pytest tests/ 25 | secrets: [ amazfit_email, amazfit_password ] 26 | 27 | wheel_build: 28 | image: python:${TAG}-buster 29 | commands: 30 | - python -m pip install --upgrade pip build wheel 31 | - python -m pip install -r requirements.txt 32 | - python -m build 33 | 34 | wheel_test: 35 | image: python:${TAG}-buster 36 | commands: 37 | - python -m pip install --upgrade pip 38 | - python -m pip install dist/*.whl 39 | - python -m pip install pytest 40 | - pytest tests/test_amazfit.py 41 | - python -m src -m amazfit -e $AMAZFIT_EMAIL -p $AMAZFIT_PASSWORD -b >/dev/null 2>&1 42 | secrets: [ amazfit_email, amazfit_password ] 43 | 44 | publish_release: 45 | image: woodpeckerci/plugin-gitea-release 46 | settings: 47 | api_key: 48 | from_secret: api_token 49 | base_url: https://codeberg.org 50 | files: 51 | - "dist/*" 52 | target: master 53 | checksum: md5 54 | when: 55 | event: tag 56 | secrets: [ api_token ] 57 | 58 | upload_to_index: 59 | image: python:3.8-buster 60 | commands: 61 | - python -m pip install twine 62 | - python -m twine check dist/* 63 | - python -m twine upload dist/* 64 | when: 65 | event: tag 66 | secrets: [ twine_username, twine_password ] 67 | 68 | matrix: 69 | TAG: 70 | - 3.8 71 | # - 3.9 72 | # - 3.10 73 | # - 3.11 74 | -------------------------------------------------------------------------------- /src/helpers.py: -------------------------------------------------------------------------------- 1 | import zipfile 2 | import zlib 3 | from pathlib import Path 4 | 5 | from Crypto.Cipher import AES 6 | from Crypto.Util.Padding import pad, unpad 7 | from loguru import logger 8 | 9 | 10 | def encode_uint32(value: int) -> bytes: 11 | """Convert 4-bytes value into a list with 4 bytes""" 12 | return ( 13 | bytes([value & 0xFF]) 14 | + bytes([(value >> 8) & 0xFF]) 15 | + bytes([(value >> 16) & 0xFF]) 16 | + bytes([(value >> 24) & 0xFF]) 17 | ) 18 | 19 | 20 | def zepp_encrypt_payload(data: bytes, key: bytes, iv: bytes) -> bytes: 21 | cipher = AES.new(key, AES.MODE_CBC, iv=iv) 22 | encrypted = cipher.encrypt(pad(data, AES.block_size)) 23 | return encrypted 24 | 25 | 26 | def zepp_decrypt_payload(encrypted: bytes, key: bytes, iv: bytes) -> bytes: 27 | cipher = AES.new(key, AES.MODE_CBC, iv=iv) 28 | decrypted = unpad(cipher.decrypt(encrypted), AES.block_size) 29 | return decrypted 30 | 31 | 32 | def build_gps_uihh(base_folder: Path) -> None: 33 | """Prepare uihh gps file""" 34 | logger.info("Preparing gps uihh file...") 35 | d = { 36 | "gps_alm.bin": 0x05, 37 | "gln_alm.bin": 0x0F, 38 | "lle_bds.lle": 0x86, 39 | "lle_gps.lle": 0x87, 40 | "lle_glo.lle": 0x88, 41 | "lle_gal.lle": 0x89, 42 | "lle_qzss.lle": 0x8A, 43 | } 44 | 45 | cep_7days = next(base_folder.glob("*cep_7days.zip")) 46 | lle_1week = next(base_folder.glob("*lle_1week.zip")) 47 | 48 | with ( 49 | zipfile.ZipFile(cep_7days, "r") as cep_archive, 50 | zipfile.ZipFile(lle_1week, "r") as lle_archive, 51 | open("gps_uihh.bin", "wb") as uihh_file, 52 | ): 53 | content = bytes() 54 | 55 | for key, value in d.items(): 56 | if value >= 0x86: 57 | file_content = lle_archive.read(key) 58 | else: 59 | file_content = cep_archive.read(key) 60 | 61 | file_header = ( 62 | bytes([1]) 63 | + bytes([value]) 64 | + encode_uint32(len(file_content)) 65 | + encode_uint32(zlib.crc32(file_content) & 0xFFFFFFFF) 66 | ) 67 | content += file_header + file_content 68 | 69 | header = ( 70 | b"UIHH" 71 | + bytes([0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]) 72 | + encode_uint32(zlib.crc32(content) & 0xFFFFFFFF) 73 | + bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) 74 | + encode_uint32(len(content)) 75 | + bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) 76 | ) 77 | 78 | content = header + content 79 | uihh_file.write(content) 80 | -------------------------------------------------------------------------------- /src/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Command line interface for huami-token. 3 | 4 | This module will eventually replace the CLI functionality in src.py 5 | as part of the project restructuring. 6 | """ 7 | 8 | import argparse 9 | import getpass 10 | from pathlib import Path 11 | 12 | from .errors import MigrationInProgressError 13 | from .helpers import build_gps_uihh 14 | from .zepp import Zepp 15 | 16 | 17 | def main(): 18 | """Main entry point for the CLI. 19 | 20 | Currently just imports and calls the original main function. 21 | This will be expanded later to follow the refactoring plan. 22 | """ 23 | parser = argparse.ArgumentParser( 24 | description="Obtain Bluetooth Auth key from Amazfit (Zepp). " 25 | "Currently only supports Amazfit.\nFor progress on Xiaomi support, see " 26 | "https://codeberg.org/argrento/huami-token/issues/119." 27 | ) 28 | parser.add_argument( 29 | "-m", 30 | "--method", 31 | choices=["amazfit", "xiaomi"], 32 | default="amazfit", 33 | required=True, 34 | help="Login method. Chose Amazfit for Zepp.", 35 | ) 36 | parser.add_argument("-e", "--email", required=False, help="Account e-mail address") 37 | 38 | parser.add_argument("-p", "--password", required=False, help="Account Password") 39 | 40 | parser.add_argument( 41 | "-b", 42 | "--bt_keys", 43 | required=False, 44 | action="store_true", 45 | help="Get bluetooth tokens of paired devices", 46 | ) 47 | 48 | parser.add_argument( 49 | "-g", 50 | "--gps", 51 | required=False, 52 | action="store_true", 53 | help="Download GPS files (AGPS_ALM, AGPSZIP, LLE, etc.)", 54 | ) 55 | 56 | parser.add_argument( 57 | "-n", 58 | "--no_logout", 59 | required=False, 60 | action="store_true", 61 | help="Do not logout, keep active session and " 62 | "display app token and access token", 63 | ) 64 | 65 | args = parser.parse_args() 66 | 67 | match args.method: 68 | case "amazfit": 69 | if args.password is None: 70 | args.password = getpass.getpass() 71 | device = Zepp(username=args.email, password=args.password) 72 | device.login() 73 | case "xiaomi": 74 | raise MigrationInProgressError() 75 | 76 | if args.bt_keys: 77 | device.get_devices() 78 | 79 | if args.gps: 80 | device.download_gps_data() 81 | build_gps_uihh(base_folder=Path.cwd()) 82 | 83 | if args.no_logout: 84 | print("\nNo logout!") 85 | print(f"app_token={device.app_token}\nlogin_token={device.login_token}") 86 | else: 87 | logout_result = device.logout() 88 | if logout_result == "ok": 89 | print("\nLogged out.") 90 | else: 91 | print("\nError logging out.") 92 | 93 | 94 | if __name__ == "__main__": 95 | main() 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Huami-token is now hosted on [codeberg.org](https://codeberg.org/argrento/huami-token/). 2 | 3 | 4 | Get it on Codeberg 5 | 6 | 7 | 8 | # Huami-token (Redesign in progress) 9 | 10 | [![status-badge](https://ci.codeberg.org/api/badges/argrento/huami-token/status.svg)](https://ci.codeberg.org/argrento/huami-token) 11 | 12 | Script to obtain watch or band bluetooth access token from Zepp (Amazfit) servers. 13 | For progress on Xiaomi support, see https://codeberg.org/argrento/huami-token/issues/119. 14 | 15 | ## About 16 | 17 | To use new versions of Amazfit and Xiaomi watches and bands with Gadgetbridge you need special unique key. 18 | Read more here: https://gadgetbridge.org/basics/pairing/huami-xiaomi-server/. 19 | 20 | ## Community 21 | 22 | If you would like to get in touch 23 | * Matrix: [`#huami-token:matrix.org`](https://matrix.to/#/#huami-token:matrix.org) 24 | 25 | ## Preparation 26 | 27 | 1. Ensure that you can login in the Zepp App with e-mail and password. If not, create new Amazfit account 28 | with e-mail and password. 29 | 2. Pair, sync and update your watch with Zepp App. Your pairing key will be stored on 30 | Huami servers. 31 | 3. Install `uv`: https://docs.astral.sh/uv/getting-started/installation/ 32 | 4. Download this repo. 33 | 5. Go to the source directory. 34 | 6. Create virtual environment: `uv venv` 35 | 7. Activate environment: `source .venv/bin/activate` 36 | 8. Install dependencies: `uv pip install -e ".[dev]"` 37 | 38 | ## Usage 39 | ``` 40 | usage: main.py [-h] -m {amazfit,xiaomi} [-e EMAIL] [-p PASSWORD] [-b] [-n] 41 | 42 | Obtain Bluetooth Auth key from Amazfit (Zepp). Currently only supports Amazfit. For progress on Xiaomi support, see https://codeberg.org/argrento/huami-token/issues/119. 43 | 44 | options: 45 | -h, --help show this help message and exit 46 | -m {amazfit,xiaomi}, --method {amazfit,xiaomi} 47 | Login method. Chose Amazfit for Zepp. 48 | -e EMAIL, --email EMAIL 49 | Account e-mail address 50 | -p PASSWORD, --password PASSWORD 51 | Account Password 52 | -b, --bt_keys Get bluetooth tokens of paired devices 53 | -g, --gps Download GPS files (AGPS_ALM, AGPSZIP, LLE, etc.) 54 | -n, --no_logout Do not logout, keep active session and display app token and access token 55 | ``` 56 | 57 | 58 | ## Logging in with Amazfit account 59 | Run script with your credentials: `python3 main.py --method amazfit --email youemail@example.com --password your_password --bt_keys`. 60 | 61 | Sample output: 62 | ```bash 63 | > python3 main.py --method amazfit --email my_email --password password --bt_keys 64 | 2025-11-14 18:41:43.316 | INFO | src.zepp:login:48 - Logging in... 65 | 2025-11-14 18:41:44.268 | INFO | src.zepp:_get_refresh_and_access_tokens:108 - Received access and refresh tokens successfully 66 | 2025-11-14 18:41:45.217 | INFO | src.zepp:login:51 - Logged in! User id: 1234567890 67 | 2025-11-14 18:41:45.217 | INFO | src.zepp:get_devices:151 - Getting linked devices... 68 | 2025-11-14 18:41:45.504 | INFO | src.zepp:get_devices:190 - Device 0: 69 | 2025-11-14 18:41:45.504 | INFO | src.zepp:get_devices:191 - MAC: AB:CD:EF:12:34:56, Active: Yes 70 | 2025-11-14 18:41:45.504 | INFO | src.zepp:get_devices:192 - Key: 0xa3c10e34e5c14637eea6b9efc06106 71 | 2025-11-14 18:41:46.400 | INFO | src.zepp:logout:219 - Logged out. 72 | 73 | ``` 74 | 75 | Here the `Key` is the unique pairing key for your watch. The `Active` tab shows whether a device is 76 | active or not. 77 | 78 | ## Logging in with Xiaomi account 79 | This is not yet reimplemented. 80 | 81 | ## AGPS 82 | This script is able to download AGPS files. But login and password are required. 83 | The following files are downloaded: 84 | * AGPS_ALM -- `cep_1week.zip` 85 | * AGPSZIP -- `cep_7days.zip` 86 | * LLE -- `lle_1week.zip` 87 | * AGPS -- `cep_pak.bin` 88 | * EPO -- `EPO.ZIP` 89 | 90 | ## Experimental: updates download 91 | This is not yet reimplemented. 92 | 93 | 94 | ## Dependencies 95 | 96 | * Python 3.7.7 97 | * argparse 98 | * requests 99 | * loguru 100 | * pycryptodome 101 | 102 | ## License 103 | 104 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 105 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 Kirill Snezhko 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | from enum import Enum 22 | 23 | 24 | class ZEPP_ENCRYPTION_PARAMS(bytes, Enum): 25 | KEY = b"xeNtBVqzDc6tuNTh" 26 | IV = b"MAAAYAAAAAAAAABg" 27 | 28 | 29 | class MAGIC(str, Enum): 30 | ZEPP_CHANNEL = "a100900101016" 31 | 32 | 33 | class URLS(str, Enum): 34 | ZEPP_TOKENS = "https://api-user-us2.zepp.com/v2/registrations/tokens" 35 | ZEPP_LOGIN = "https://api-mifit-us2.zepp.com/v2/client/login" 36 | ZEPP_LOGOUT = "https://api-mifit-us2.zepp.com/v1/client/logout" 37 | ZEPP_DEVICES = "https://api-mifit.zepp.com/users/{user_id}/devices" 38 | ZEPP_GPS = "https://api-mifit-us2.zepp.com/apps/com.xiaomi.hm.health/fileTypes/{file_type}/files" 39 | 40 | 41 | class PAYLOADS(dict, Enum): 42 | ZEPP_TOKENS = { 43 | "emailOrPhone": None, 44 | "state": "REDIRECTION", 45 | "client_id": "HuaMi", 46 | "password": None, 47 | "redirect_uri": "https://s3-us-west-2.amazonaws.com/hm-registration/successsignin.html", 48 | "region": "us-west-2", 49 | "token": ["access", "refresh"], 50 | "country_code": "US", 51 | } 52 | ZEPP_LOGIN = { 53 | "code": None, 54 | "device_id": None, 55 | "device_model": "android_phone", 56 | "app_version": "9.12.5", 57 | "dn": "api-mifit.zepp.com,api-user.zepp.com,api-mifit.zepp.com,api-watch.zepp.com,app-analytics.zepp.com,auth.zepp.com,api-analytics.zepp.com", 58 | "third_name": "huami", 59 | "source": "com.huami.watch.hmwatchmanager:9.12.5:151689", 60 | "app_name": "com.huami.midong", 61 | "country_code": "US", 62 | "grant_type": "access_token", 63 | "allow_registration": "false", 64 | "lang": "en", 65 | "countryState": "US-NY", 66 | } 67 | 68 | 69 | class HEADERS(dict, Enum): 70 | ZEPP_TOKENS = { 71 | "app_name": "com.huami.midong", 72 | "appname": "com.huami.midong", 73 | "cv": "151689_9.12.5", 74 | "v": "2.0", 75 | "appplatform": "android_phone", 76 | "vb": "202509151347", 77 | "vn": "9.12.5", 78 | "user-agent": "Zepp/9.12.5 (Pixel 4; Android 12; Density/2.75)", 79 | "x-hm-ekv": "1", 80 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 81 | "accept-encoding": "gzip", 82 | } 83 | 84 | ZEPP_DEVICES = { 85 | "hm-privacy-diagnostics": "false", 86 | "country": "US", 87 | "appplatform": "android_phone", 88 | "hm-privacy-ceip": "true", 89 | "x-request-id": None, 90 | "timezone": "Europe/London", 91 | "channel": MAGIC.ZEPP_CHANNEL.value, 92 | "vb": "202509151347", 93 | "cv": "151689_9.12.5", 94 | "appname": "com.huami.midong", 95 | "v": "2.0", 96 | "vn": "9.12.5", 97 | "apptoken": None, 98 | "lang": "en_US", 99 | "user-agent": "Zepp/9.12.5 (Pixel 4; Android 12; Density/2.75)", 100 | "accept-encoding": "gzip", 101 | } 102 | 103 | ZEPP_GPS = { 104 | "hm-privacy-diagnostics": "false", 105 | "country": "US", 106 | "appplatform": "android_phone", 107 | "hm-privacy-ceip": "false", 108 | "x-request-id": None, 109 | "timezone": "Europe/London", 110 | "channel": MAGIC.ZEPP_CHANNEL.value, 111 | "vb": "202509151347", 112 | "cv": "151689_9.12.5", 113 | "appname": "com.huami.midong", 114 | "v": "2.0", 115 | "vn": "9.12.5", 116 | "apptoken": None, 117 | "lang": "en_US", 118 | "user-agent": "Zepp/9.12.5 (Pixel 4; Android 12; Density/2.75)", 119 | "accept-encoding": "gzip", 120 | } 121 | 122 | ZEPP_LOGOUT = { 123 | "app_name": "com.huami.midong", 124 | "hm-privacy-ceip": "false", 125 | "accept-language": "en-US", 126 | "appname": "com.huami.midong", 127 | "cv": "151689_9.12.5", 128 | "v": "2.0", 129 | "appplatform": "android_phone", 130 | "vb": "202509151347", 131 | "vn": "9.12.5", 132 | "user-agent": "Zepp/9.12.5 (Pixel 4; Android 12; Density/2.75)", 133 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 134 | } 135 | 136 | 137 | class URL_PARAMS(dict, Enum): 138 | ZEPP_DEVICES = { 139 | "r": None, # yes, twice 140 | "enableMultiDeviceOnMultiType": ["true", "true"], 141 | "userid": None, 142 | "appid": None, 143 | "channel": MAGIC.ZEPP_CHANNEL.value, 144 | "country": "US", 145 | "cv": "151689_9.12.5", 146 | "device": "android_32", 147 | "device_type": "android_phone", 148 | "enableMultiDevice": "true", 149 | "lang": "en_US", 150 | "timezone": "Europe/London", 151 | "v": "2.0", 152 | } 153 | 154 | ZEPP_GPS = { 155 | "r": None, # yes, twice again 156 | "userid": None, 157 | "appid": None, 158 | "channel": MAGIC.ZEPP_CHANNEL.value, 159 | "country": "US", 160 | "cv": "151689_9.12.5", 161 | "device": "android_32", 162 | "device_type": "android_phone", 163 | "lang": "en_US", 164 | "timezone": "Europe/Berlin", 165 | "v": "2.0", 166 | } 167 | -------------------------------------------------------------------------------- /src/zepp.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 Kirill Snezhko 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | import json 22 | import secrets 23 | import urllib.parse 24 | import uuid 25 | 26 | import requests 27 | from loguru import logger 28 | 29 | from .constants import HEADERS, PAYLOADS, URL_PARAMS, URLS, ZEPP_ENCRYPTION_PARAMS 30 | from .errors import AuthenticationError, DeviceError, HuamiTokenError, LogoutError 31 | from .helpers import zepp_encrypt_payload 32 | 33 | 34 | class Zepp: 35 | def __init__(self, username: str, password: str) -> None: 36 | self.username: str = username 37 | self.password: str = password 38 | 39 | self.access_token: str | None = None 40 | self.refresh_token: str | None = None 41 | self.login_token: str | None = None 42 | self.app_token: str | None = None 43 | 44 | self.user_id: str | None = None 45 | 46 | def login(self) -> None: 47 | try: 48 | logger.info("Logging in...") 49 | self._get_refresh_and_access_tokens() 50 | self._login() 51 | logger.info(f"Logged in! User id: {self.user_id}") 52 | except HuamiTokenError as e: 53 | logger.exception(f"Authentication error occurred: {e}") 54 | 55 | def _get_refresh_and_access_tokens(self) -> None: 56 | """ 57 | Get the first pair of tokens: refresh and access. If login and password are correct, 58 | the server responds with a 303 redirect to a URL containing the tokens in the query parameters. 59 | 60 | Do not follow the redirect, just extract the tokens from the "Location" URL. 61 | """ 62 | 63 | # Prepare payload 64 | payload = PAYLOADS.ZEPP_TOKENS.value.copy() 65 | payload["emailOrPhone"] = self.username 66 | payload["password"] = self.password 67 | encoded_payload = urllib.parse.urlencode(payload, doseq=True).encode() 68 | logger.debug(f"{encoded_payload=}") 69 | encrypted_payload = zepp_encrypt_payload( 70 | encoded_payload, 71 | key=ZEPP_ENCRYPTION_PARAMS.KEY.value, 72 | iv=ZEPP_ENCRYPTION_PARAMS.IV.value, 73 | ) 74 | response = requests.post( 75 | URLS.ZEPP_TOKENS.value, 76 | data=encrypted_payload, 77 | headers=HEADERS.ZEPP_TOKENS.value, 78 | allow_redirects=False, 79 | ) 80 | if response.status_code != 303: 81 | raise AuthenticationError( 82 | code="no-redirect", 83 | message=f"No redirect after token request, status code is {response.status_code} instead of 303", 84 | ) 85 | 86 | redirect_location = response.headers.get("Location") 87 | if not redirect_location: 88 | raise AuthenticationError( 89 | code="no-location", 90 | message="No redirect location found in the response headers", 91 | ) 92 | else: 93 | logger.debug(f"Redirect location: {redirect_location}") 94 | 95 | parsed_redirect_url = urllib.parse.urlparse(redirect_location) 96 | query_params = urllib.parse.parse_qs(parsed_redirect_url.query) 97 | 98 | # Check for refresh and access tokens in the redirect URL 99 | self.refresh_token = query_params.get("refresh", [None])[0] 100 | logger.debug(f"Refresh token: {self.refresh_token}") 101 | self.access_token = query_params.get("access", [None])[0] 102 | logger.debug(f"Access token: {self.access_token}") 103 | if not self.refresh_token or not self.access_token: 104 | raise AuthenticationError( 105 | code="no-tokens", 106 | message="No refresh or access token found in the redirect URL", 107 | ) 108 | logger.info(f"Received access and refresh tokens successfully") 109 | 110 | def _login(self): 111 | """Perform login to get login_token and app_token using access_token""" 112 | payload = PAYLOADS.ZEPP_LOGIN.value.copy() 113 | payload["code"] = self.access_token 114 | payload["device_id"] = str(uuid.uuid4()) 115 | 116 | # headers are the same as for token request 117 | response = requests.post( 118 | URLS.ZEPP_LOGIN.value, 119 | data=payload, 120 | headers=HEADERS.ZEPP_TOKENS.value, 121 | ) 122 | if response.status_code != 200: 123 | raise AuthenticationError( 124 | code="login-failed", 125 | message=f"Login request failed with status code {response.status_code}", 126 | ) 127 | response_data = response.json() 128 | 129 | # Extract login_token and app_token from the response 130 | token_info = response_data.get("token_info", {}) 131 | self.login_token = token_info.get("login_token") 132 | logger.debug(f"Login token: {self.login_token}") 133 | self.app_token = token_info.get("app_token") 134 | logger.debug(f"App token: {self.app_token}") 135 | if not self.login_token or not self.app_token: 136 | raise AuthenticationError( 137 | code="no-login-tokens", 138 | message="No login_token or app_token found in the login response", 139 | ) 140 | 141 | # Extract user id 142 | self.user_id = token_info.get("user_id") 143 | if not self.user_id: 144 | raise AuthenticationError( 145 | code="no-user-id", 146 | message="No user_id found in the login response", 147 | ) 148 | 149 | def get_devices(self): 150 | """Get the list of devices associated with the account""" 151 | logger.info("Getting linked devices...") 152 | if not self.user_id or not self.app_token: 153 | raise DeviceError("Cannot get devices without user_id and app_token") 154 | 155 | params = URL_PARAMS.ZEPP_DEVICES.value.copy() 156 | params["r"] = [str(uuid.uuid4())] * 2 # yes, twice 157 | params["userid"] = self.user_id 158 | params["appid"] = secrets.randbits(64) # random 64-bit integer 159 | 160 | headers = HEADERS.ZEPP_DEVICES.value.copy() 161 | headers["x-request-id"] = str(uuid.uuid4()) 162 | headers["apptoken"] = self.app_token 163 | 164 | response = requests.get( 165 | URLS.ZEPP_DEVICES.value.format(user_id=self.user_id), 166 | params=params, 167 | headers=headers, 168 | ) 169 | if response.status_code != 200: 170 | raise DeviceError( 171 | code="get-devices-failed", 172 | message=f"Get devices request failed with status code {response.status_code}", 173 | ) 174 | response_data = response.json() 175 | items = response_data.get("items", []) 176 | if not items: 177 | raise DeviceError( 178 | code="no-devices", 179 | message="No devices found in the response", 180 | ) 181 | 182 | for item_id, item in enumerate(items): 183 | mac = item.get("macAddress", "??:??:??:??:??:??") 184 | active = "Yes" if item.get("activeStatus", 0) else "No" 185 | additional_info_str = item.get("additionalInfo", {}) 186 | additional_info = ( 187 | json.loads(additional_info_str) if additional_info_str else {} 188 | ) 189 | auth_key = additional_info.get("auth_key", "??") 190 | logger.info(f"Device {item_id}:") 191 | logger.info(f"MAC: {mac}, Active: {active}") 192 | logger.info(f"Key: 0x{auth_key}") 193 | 194 | def download_gps_data(self) -> str: 195 | logger.info("Downloading GPS data...") 196 | if not self.user_id or not self.app_token: 197 | raise DeviceError("Cannot download GPS data without user_id and app_token") 198 | 199 | params = URL_PARAMS.ZEPP_GPS.value.copy() 200 | params["r"] = [str(uuid.uuid4())] * 2 # yes, twice again 201 | params["userid"] = self.user_id 202 | params["appid"] = secrets.randbits(64) # random 64-bit integer again 203 | 204 | headers = HEADERS.ZEPP_DEVICES.value.copy() 205 | headers["x-request-id"] = str(uuid.uuid4()) 206 | headers["apptoken"] = self.app_token 207 | 208 | for file_type in ["AGPS_ALM", "AGPSZIP", "LLE", "AGPS", "EPO", "LTO"]: 209 | response = requests.get( 210 | URLS.ZEPP_GPS.value.format(file_type=file_type), 211 | params=params, 212 | headers=headers, 213 | ) 214 | if response.status_code != 200: 215 | raise DeviceError( 216 | code="get-gps-failed", 217 | message=f"Get GPS data failed with status code {response.status_code}", 218 | ) 219 | response_data = response.json() 220 | if file_url := response_data[0].get("fileUrl"): 221 | file_name = file_url.split("/")[-1] 222 | 223 | with requests.get( 224 | file_url, stream=True, timeout=10, headers=headers 225 | ) as file_download_response: 226 | file_download_response.raise_for_status() 227 | with open(file_name, "wb") as gps_file: 228 | logger.info(f"Downloading {file_type} to {file_name}...") 229 | for chunk in file_download_response.iter_content(8192): 230 | if chunk: 231 | gps_file.write(chunk) 232 | 233 | def logout(self) -> str: 234 | """Logout from Zepp account""" 235 | if not self.login_token: 236 | logger.warning("No login token, cannot logout") 237 | return "Error" 238 | 239 | payload = {"login_token": self.login_token, "os_verison": "vnull"} 240 | response = requests.post( 241 | URLS.ZEPP_LOGOUT.value, 242 | data=payload, 243 | headers=HEADERS.ZEPP_LOGOUT.value, 244 | ) 245 | if response.status_code != 200: 246 | raise LogoutError( 247 | code="logout-failed", 248 | message=f"Logout request failed with status code {response.status_code}", 249 | ) 250 | 251 | response_data = response.json() 252 | if response_data.get("result") != "ok": 253 | raise LogoutError( 254 | code="logout-error", 255 | message=f"Logout failed with response: {response_data}", 256 | ) 257 | 258 | logger.info("Logged out.") 259 | return response_data["result"] 260 | 261 | 262 | if __name__ == "__main__": 263 | pass 264 | --------------------------------------------------------------------------------